Add a passphrase-locked vault
CI / test (push) Successful in 2m2s
Release / release (push) Successful in 2m52s

A second notebook whose pages are encrypted with a key made from a passphrase plus the server key. It is unlocked into memory only (15 minutes idle, Lock, or sign-out), is not in search, lists or downloads of the ordinary notebook, and limits wrong guesses.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-11-01 01:40:35 -07:00
1 parent 7ff424fa55
commit c08a8b8718
12 files changed
+706 -28

No files matched your search

+1
View File
@@ -171,6 +171,7 @@ func firstOf(s ...string) string {
}
func (s *Server) logout(w http.ResponseWriter, r *http.Request, p principal) {
s.lockVault(w, r)
setCookie(w, s.cfg.Get(), sessionCookie, "", "/", 0)
writeJSON(w, map[string]bool{"ok": true})
}
+28 -2
View File
@@ -15,6 +15,7 @@ import (
"path/filepath"
"strings"
"sync"
"time"
"git.veillax.com/Veillax/mako/internal/config"
"git.veillax.com/Veillax/mako/internal/oidc"
@@ -34,6 +35,11 @@ type Server struct {
mu sync.Mutex
books map[string]*store.Store
vmu sync.Mutex
vstate map[string]*vaultState // unlocked vaults, by the folder name of their owner
vtoken map[string]string // vault cookie value to that folder name
vfails map[string][]time.Time // recent wrong passphrases, by the same name
}
// principal is who is making a request, with the notebook that is theirs alone.
@@ -49,17 +55,23 @@ func New(h *config.Holder, version string) (*Server, error) {
return nil, err
}
return &Server{cfg: h, version: version, books: map[string]*store.Store{},
vstate: map[string]*vaultState{}, vtoken: map[string]string{}, vfails: map[string][]time.Time{},
oidc: oidc.New(c.OIDC.Issuer, c.OIDC.ClientID, c.OIDC.ClientSecret, c.RedirectURL())}, nil
}
// OIDC is the sign-in client, for the check command.
func (s *Server) OIDC() *oidc.Client { return s.oidc }
// userKey names a person's folder, from a hash of who they are.
func userKey(c config.Config, sub string) string {
sum := sha256.Sum256([]byte(c.OIDC.Issuer + "|" + sub))
return hex.EncodeToString(sum[:16])
}
// notebook opens the notes that belong to one person. Their folder is named from a hash of who they
// are, so nothing a person controls ever becomes part of a path.
func (s *Server) notebook(c config.Config, sub, name, email string) (*store.Store, error) {
sum := sha256.Sum256([]byte(c.OIDC.Issuer + "|" + sub))
key := hex.EncodeToString(sum[:16])
key := userKey(c, sub)
s.mu.Lock()
defer s.mu.Unlock()
if st, ok := s.books[key]; ok {
@@ -104,6 +116,20 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/find", s.api(s.find))
mux.HandleFunc("GET /api/export", s.api(s.export))
mux.HandleFunc("GET /api/vault", s.api(s.vaultInfo))
mux.HandleFunc("POST /api/vault", s.api(s.vaultCreate))
mux.HandleFunc("POST /api/vault/unlock", s.api(s.vaultUnlock))
mux.HandleFunc("POST /api/vault/lock", s.api(s.vaultLock))
mux.HandleFunc("GET /api/vault/pages", s.vaultAPI(s.pages))
mux.HandleFunc("GET /api/vault/notes", s.vaultAPI(s.listNotes))
mux.HandleFunc("POST /api/vault/notes", s.vaultAPI(s.createNote))
mux.HandleFunc("GET /api/vault/notes/{id}", s.vaultAPI(s.getNote))
mux.HandleFunc("PUT /api/vault/notes/{id}", s.vaultAPI(s.updateNote))
mux.HandleFunc("DELETE /api/vault/notes/{id}", s.vaultAPI(s.deleteNote))
mux.HandleFunc("POST /api/vault/notes/{id}/restore", s.vaultAPI(s.restoreNote))
mux.HandleFunc("DELETE /api/vault/trash", s.vaultAPI(s.emptyTrash))
mux.HandleFunc("GET /api/vault/find", s.vaultAPI(s.find))
static, _ := fs.Sub(staticFiles, "static")
mux.Handle("GET /", http.FileServerFS(static))
return s.secure(mux)
+3 -1
View File
@@ -129,7 +129,8 @@ function updateNav() {
tree(here),
S.tags.length ? [h('div', { class: 'navsec' }, 'Tags'), S.tags.map(t => navItem('#/tag/' + encodeURIComponent(t.tag), 'tag/' + t.tag, t.tag, 'tag', t.count))] : null,
h('div', { class: 'navsec' }, 'Other'),
navItem('#/trash', 'trash', 'Trash', 'trash', S.counts.trash || null));
navItem('#/trash', 'trash', 'Trash', 'trash', S.counts.trash || null),
navItem('#/vault', 'vault', 'Vault', 'lock'));
}
function buildShell() {
@@ -175,6 +176,7 @@ async function show() {
case 'pinned': page = feed({ view: 'pinned', title: 'Pinned', desc: 'Notes you pinned to keep close.' }); break;
case 'tag': page = feed({ view: 'all', tag: r.arg, title: '#' + r.arg, desc: 'Every page with this tag.' }); break;
case 'trash': page = feed({ view: 'trash', title: 'Trash', desc: 'Deleted pages wait here until you empty the trash.' }); break;
case 'vault': page = vaultView(r.arg); break;
case 'settings': page = settingsPage(); break;
default: page = feed({ view: 'all', composer: true, title: 'Home', desc: 'Write anything down. Give it a title and a place later, or never.' });
}
+23 -14
View File
@@ -33,8 +33,8 @@ const SLASH = [
{ name: 'Quote', hint: '>', keys: 'quote callout', text: '> ' },
{ name: 'Code block', hint: '```', keys: 'code pre', text: '```\n\n```', caret: 4 },
{ name: 'Divider', hint: '---', keys: 'divider line hr rule', text: '---\n' },
{ name: 'Link to a page', hint: '[[', keys: 'link page wiki mention', text: '[[', again: true },
{ name: 'Link to today', hint: () => isoDay(new Date()), keys: 'today date journal day', text: () => '[[' + isoDay(new Date()) + ']]' },
{ name: 'Link to a page', hint: '[[', keys: 'link page wiki mention', text: '[[', again: true, link: true },
{ name: 'Link to today', hint: () => isoDay(new Date()), keys: 'today date journal day', text: () => '[[' + isoDay(new Date()) + ']]', link: true },
{ name: 'Current time', hint: () => hhmm(new Date()), keys: 'time clock now', text: () => hhmm(new Date()) + ' ' },
];
@@ -76,7 +76,8 @@ function pageChoices(q) {
return list;
}
function enhance(ta, wrap) {
// With plain set (the vault) there are no page links, so no [[ picker and no link commands.
function enhance(ta, wrap, plain) {
const pop = makePopup(ta, wrap);
function check() {
@@ -84,7 +85,7 @@ function enhance(ta, wrap) {
if (document.activeElement !== ta || ta.selectionStart !== ta.selectionEnd) return pop.hide();
const pos = ta.selectionStart, ls = lineStart(ta.value, pos), before = ta.value.slice(ls, pos);
let m;
if ((m = /\[\[([^\[\]|\n]*)$/.exec(before))) {
if (!plain && (m = /\[\[([^\[\]|\n]*)$/.exec(before))) {
const from = pos - m[1].length;
pop.show(pageChoices(m[1]), it => {
const closed = ta.value.startsWith(']]', pos);
@@ -92,7 +93,7 @@ function enhance(ta, wrap) {
}, pos);
} else if ((m = /^(\s*)\/([a-z0-9]{0,16})$/i.exec(before))) {
const q = m[2].toLowerCase(), from = ls + m[1].length;
pop.show(SLASH.filter(c => !q || c.name.toLowerCase().includes(q) || c.keys.includes(q)), c => {
pop.show(SLASH.filter(c => !(plain && c.link) && (!q || c.name.toLowerCase().includes(q) || c.keys.includes(q))), c => {
const text = typeof c.text === 'function' ? c.text() : c.text;
replaceRange(ta, from, pos, text);
// in the live view a command that adds lines redraws it, and the box being typed in is a new one
@@ -154,7 +155,7 @@ function enhance(ta, wrap) {
// ---------- live preview: every line shows rendered except the one being edited ----------
// The text itself stays in the hidden text box `ta`; this view splices each edit back into it, so saving,
// conflicts and the other modes need to know nothing about it. A fenced code block is edited as a whole.
function makeLive(ta) {
function makeLive(ta, plain) {
const el = h('div', { class: 'live', hidden: true });
// act: the block starting at line s is being edited, caret at col. With e set, lines s to e are edited
// together as raw text (a selection across lines), and sel is [start, end, direction] inside them.
@@ -178,7 +179,7 @@ function makeLive(ta) {
function rendered(b) {
const text = lines.slice(b.s, b.e + 1).join('\n'), box = h('div', { class: 'live-line' });
if (text.trim()) {
const root = md(text, (_, checked) => { lines[b.s] = lines[b.s].replace(/\[( |x|X)\]/, checked ? '[x]' : '[ ]'); commit(); draw(); }, true);
const root = md(text, (_, checked) => { lines[b.s] = lines[b.s].replace(/\[( |x|X)\]/, checked ? '[x]' : '[ ]'); commit(); draw(); }, true, plain);
const m = /^\s*(\d+)[.)]\s/.exec(text);
if (m) root.querySelector('ol')?.setAttribute('start', m[1]);
box.append(root);
@@ -235,7 +236,7 @@ function makeLive(ta) {
const empty = lines.length === 1 && !lines[0];
const t = h('textarea', { class: 'live-edit', rows: 1, spellcheck: 'true', 'aria-label': 'Editing', placeholder: empty ? "Write something, or type '/' for commands, '[[' to link a page" : '' });
t.value = lines.slice(b.s, b.e + 1).join('\n');
enhance(t, el);
enhance(t, el, plain);
const multi = b.fence || b.span; // a code block or a selection over lines: Enter just adds a line
t.addEventListener('input', () => {
const fresh = t.value.split('\n');
@@ -357,7 +358,9 @@ function makeLive(ta) {
// ---------- the page editor: autosaves title and text, and notices when a page changed elsewhere ----------
// With no note it starts a new page, which is only created once something is typed.
function pageEditor({ note, parent = '', fixedTitle, onCreated, onSaved }) {
// With vault set the page lives in the vault: its own API, and no links into the ordinary notebook.
function pageEditor({ note, parent = '', fixedTitle, onCreated, onSaved, vault = false }) {
const base = vault ? '/api/vault' : '/api';
let cur = note || null, titleDirty = false, bodyDirty = false, saving = false, queued = false, blocked = false;
let timer = null, retry = null, badTitle = null, mode = 'live', lastEdit = 'live';
@@ -368,13 +371,13 @@ function pageEditor({ note, parent = '', fixedTitle, onCreated, onSaved }) {
const ta = h('textarea', { class: 'write big', 'aria-label': 'Page text', placeholder: "Write something, or type '/' for commands, '[[' to link a page" });
ta.value = note?.body ?? '';
const view = h('div', { class: 'view', hidden: true });
const live = makeLive(ta);
const live = makeLive(ta, vault);
const wrap = h('div', { class: 'editor-wrap' }, ta, live.el, view);
const status = h('span', { class: 'sub', role: 'status' });
const notice = h('div', { class: 'notice notice-warning', hidden: true });
const say = t => { status.textContent = t; };
const fit = () => { ta.style.height = 'auto'; ta.style.height = ta.scrollHeight + 2 + 'px'; };
enhance(ta, wrap);
enhance(ta, wrap, vault);
const dirty = () => titleDirty || bodyDirty;
const ctl = { save, dirty };
@@ -393,12 +396,12 @@ function pageEditor({ note, parent = '', fixedTitle, onCreated, onSaved }) {
saving = true; say('Saving');
try {
let n;
if (!cur) n = await api('POST', '/api/notes', { title: sendTitle ? t : '', body: b, parent }, { keepalive: b.length < 15000 });
if (!cur) n = await api('POST', base + '/notes', { title: sendTitle ? t : '', body: b, parent }, { keepalive: b.length < 15000 });
else {
const payload = { base: cur.updated };
if (sendTitle) payload.title = t;
if (sendBody) payload.body = b;
n = await api('PUT', '/api/notes/' + cur.id, payload, { keepalive: b.length < 15000 });
n = await api('PUT', base + '/notes/' + cur.id, payload, { keepalive: b.length < 15000 });
}
const created = !cur;
cur = n;
@@ -419,6 +422,12 @@ function pageEditor({ note, parent = '', fixedTitle, onCreated, onSaved }) {
h('a', { class: 'btn-link', href: '/auth/login?next=' + encodeURIComponent(location.hash), target: '_blank', rel: 'noopener' }, 'Sign in again'),
h('span', { class: 'sub' }, 'Opens in a new tab. Come back afterwards and this page saves itself.')]);
retry = setTimeout(save, 4000);
} else if (e.status === 423) {
say('Not saved');
flag([h('span', { class: 'grow' }, 'The vault locked itself, so your latest changes are not saved yet. Your text is still here.'),
h('a', { class: 'btn-link', href: '#/vault', target: '_blank', rel: 'noopener' }, 'Unlock the vault'),
h('span', { class: 'sub' }, 'Opens in a new tab. Come back afterwards and this page saves itself.')]);
retry = setTimeout(save, 4000);
} else {
say('Not saved');
if (e.offline || e.status >= 500) { flag(e.message + ' Trying again.'); retry = setTimeout(save, 5000); }
@@ -449,7 +458,7 @@ function pageEditor({ note, parent = '', fixedTitle, onCreated, onSaved }) {
ta.addEventListener('input', () => { bodyDirty = true; say('Unsaved'); fit(); later(); });
// Write shows the text box; Read shows the page as it will look, with tasks you can tick and links you can follow.
function render() { view.replaceChildren(ta.value.trim() ? md(ta.value, ticked, true) : h('p', { class: 'dim' }, 'Nothing here yet. Click to start writing.')); }
function render() { view.replaceChildren(ta.value.trim() ? md(ta.value, ticked, true, vault) : h('p', { class: 'dim' }, 'Nothing here yet. Click to start writing.')); }
function ticked(line, checked) {
const lines = ta.value.split('\n');
lines[line] = lines[line].replace(/\[( |x|X)\]/, checked ? '[x]' : '[ ]');
+1
View File
@@ -13,6 +13,7 @@
<script src="toolkit.js"></script>
<script src="markdown.js"></script>
<script src="editor.js"></script>
<script src="vault.js"></script>
<script src="app.js"></script>
</body>
</html>
+12 -11
View File
@@ -13,21 +13,22 @@ function wikiLink(target, text) {
return h('a', { class: 'wikilink missing', href: '#/link/' + encodeURIComponent(title), title: 'No page called "' + title + '" yet. Click to create it.' }, text || title);
}
function inline(text) {
// With plain set (the vault) [[links]] and #tags stay as typed, so a page there never points into the other notebook.
function inline(text, plain) {
const re = new RegExp(INLINE_SRC, 'gu'), out = [];
let last = 0, m;
while ((m = re.exec(text))) {
if (m.index > last) out.push(text.slice(last, m.index));
last = re.lastIndex;
if (m[1] != null) out.push(h('code', {}, m[1]));
else if (m[2] != null) out.push(h('strong', {}, inline(m[2])));
else if (m[3] != null) out.push(h('em', {}, inline(m[3])));
else if (m[4] != null) out.push(wikiLink(m[4], m[5]));
else if (m[2] != null) out.push(h('strong', {}, inline(m[2], plain)));
else if (m[3] != null) out.push(h('em', {}, inline(m[3], plain)));
else if (m[4] != null) out.push(plain ? m[0] : wikiLink(m[4], m[5]));
else if (m[6] != null) out.push(extLink(m[7], m[6]));
else if (m[8] != null) out.push(extLink(m[8], m[8]));
else {
const t = m[9].replace(/[/-]+$/, '');
if (!t || /^\d+$/.test(t)) out.push('#' + m[9]);
if (plain || !t || /^\d+$/.test(t)) out.push('#' + m[9]);
else out.push(tagChip(t.toLowerCase()), m[9].slice(t.length));
}
}
@@ -36,13 +37,13 @@ function inline(text) {
}
// md renders a page. onTask(lineIndex, checked) makes the checkboxes work; without it they are read-only.
function md(src, onTask, big) {
function md(src, onTask, big, plain) {
const root = h('div', { class: 'md' + (big ? ' md-big' : '') }), lines = src.split('\n');
let para = [], i = 0, m;
const flush = () => {
if (!para.length) return;
const p = h('p');
para.forEach((t, k) => { if (k) p.append(h('br')); p.append(...inline(t)); });
para.forEach((t, k) => { if (k) p.append(h('br')); p.append(...inline(t, plain)); });
root.append(p); para = [];
};
while (i < lines.length) {
@@ -53,12 +54,12 @@ function md(src, onTask, big) {
i++; root.append(h('pre', {}, h('code', {}, code.join('\n')))); continue;
}
if (!line.trim()) { flush(); i++; continue; }
if ((m = /^(#{1,3})\s+(.+)$/.exec(line))) { flush(); root.append(h('h' + (m[1].length + 2), { class: 'md-h' }, inline(m[2]))); i++; continue; }
if ((m = /^(#{1,3})\s+(.+)$/.exec(line))) { flush(); root.append(h('h' + (m[1].length + 2), { class: 'md-h' }, inline(m[2], plain))); i++; continue; }
if (/^\s*(-{3,}|\*{3,})\s*$/.test(line)) { flush(); root.append(h('hr')); i++; continue; }
if (/^>/.test(line)) {
flush(); const q = [];
while (i < lines.length && /^>/.test(lines[i])) q.push(lines[i++].replace(/^>\s?/, ''));
const bq = h('blockquote'); q.forEach((t, k) => { if (k) bq.append(h('br')); bq.append(...inline(t)); }); root.append(bq); continue;
const bq = h('blockquote'); q.forEach((t, k) => { if (k) bq.append(h('br')); bq.append(...inline(t, plain)); }); root.append(bq); continue;
}
if (/^(\s*)([-*+]|\d+[.)])\s+/.test(line)) {
flush(); const ordered = /^\s*\d/.test(line), list = h(ordered ? 'ol' : 'ul');
@@ -68,8 +69,8 @@ function md(src, onTask, big) {
if (m[3] != null) {
const box = h('input', { type: 'checkbox', checked: m[3] !== ' ', disabled: !onTask, 'aria-label': m[4] || 'Task' });
if (onTask) box.addEventListener('change', () => onTask(at, box.checked));
li.append(box, h('span', { class: m[3] !== ' ' ? 'done' : '' }, inline(m[4])));
} else li.append(...inline(m[4]));
li.append(box, h('span', { class: m[3] !== ' ' ? 'done' : '' }, inline(m[4], plain)));
} else li.append(...inline(m[4], plain));
list.append(li); i++;
}
root.append(list); continue;
+11
View File
@@ -336,3 +336,14 @@
textarea.live-edit { display:block; width:100%; min-height:1.7em; margin:0 0 0 calc(-1 * (var(--s2) + 2px)); padding:0 0 0 var(--s2); border:0; border-left:2px solid var(--accent); border-radius:0; background:var(--accent-soft); font-family:var(--font); font-size:var(--text-base); line-height:1.7; resize:none; overflow:hidden; }
textarea.live-edit:focus { outline:none; border-color:var(--accent); }
.live-tail { min-height:30vh; cursor:text; }
/* the vault */
.vault-gate { display:flex; justify-content:center; padding-top:var(--s6); }
.vault-gate .card { width:100%; max-width:420px; display:flex; flex-direction:column; gap:var(--s3); }
.vault-icon { align-self:center; color:var(--accent); }
.vault-form { display:flex; flex-direction:column; gap:var(--s3); }
.vault-list { list-style:none; margin:0; padding:var(--s1); }
.vault-row { display:flex; align-items:center; gap:var(--s3); padding:var(--s2) var(--s3); border-radius:var(--radius); flex-wrap:wrap; }
.vault-row:hover { background:var(--accent-soft); }
.vault-name { display:flex; align-items:center; gap:var(--s2); flex:1; min-width:0; color:var(--text); text-decoration:none; }
.clip1 { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+159
View File
@@ -0,0 +1,159 @@
'use strict';
// ---------- the vault: pages that open only with a passphrase ----------
// The server holds the vault's key only while it is unlocked, and forgets it when you lock it, sign out
// or leave it alone for a quarter of an hour. Its pages never show in the ordinary notebook's lists, search
// or download, and they cannot link to it.
const VAULT = '/api/vault';
function vaultView(arg) {
const root = h('div', { class: 'page vault' });
let ed = null, dead = false;
const view = { el: root, title: 'Vault', dispose() { dead = true; ed?.dispose?.(); } };
const lock = async () => {
await flushAll();
await api('POST', VAULT + '/lock');
location.hash = '#/vault';
show();
};
function passField(label, autocomplete) {
const input = h('input', { type: 'password', class: 'vault-pass', autocomplete, 'aria-label': label, placeholder: label, required: true });
return input;
}
// one form for creating and for unlocking
function gate({ title, text, fields, submit, label, extra }) {
const err = h('p', { class: 'err small', role: 'alert', hidden: true });
const form = h('form', { class: 'vault-form', onsubmit: async e => {
e.preventDefault();
err.hidden = true;
try { await submit(); }
catch (x) { err.textContent = x.message; err.hidden = false; fields[0].select(); }
} }, ...fields, err, h('button', { class: 'btn', type: 'submit' }, icon('lock', 16), label));
fill(root, h('div', { class: 'vault-gate' }, card('', h('div', { class: 'vault-icon' }, icon('lock', 22)), h('h1', { class: 'login-h' }, title),
h('p', { class: 'dim center-text' }, text), form, extra)));
setTimeout(() => fields[0].focus());
}
function create() {
const a = passField('Passphrase', 'new-password'), b = passField('Type it again', 'new-password');
gate({
title: 'Make your vault',
text: 'A place for pages that need more than a sign-in. They are locked with a passphrase, and Mako cannot read them until you unlock them.',
fields: [a, b], label: 'Create vault',
extra: h('p', { class: 'notice notice-warning small' }, icon('alert', 16), 'If you forget the passphrase, the pages in the vault cannot be recovered. Nobody can reset it, not even whoever runs Mako.'),
async submit() {
if (a.value !== b.value) throw new Error('The two passphrases are different.');
await api('POST', VAULT, { passphrase: a.value });
a.value = b.value = '';
show();
},
});
}
function unlock() {
const a = passField('Passphrase', 'current-password');
gate({
title: 'The vault is locked',
text: 'Type your passphrase to open it. It locks again when you leave it alone for a while, or when you sign out.',
fields: [a], label: 'Unlock',
async submit() {
try { await api('POST', VAULT + '/unlock', { passphrase: a.value }); }
catch (e) { throw new Error(e.data?.code === 'wrong_passphrase' ? 'That is not the passphrase. Try again.' : e.message); }
a.value = '';
show();
},
});
}
// the pages in the vault, or its trash
async function list(trash) {
fill(root, skeleton());
const r = await api('GET', VAULT + '/notes?limit=200&view=' + (trash ? 'trash' : 'all'));
if (dead) return;
const rows = r.notes.map(n => h('li', { class: 'vault-row' },
h('a', { class: 'vault-name', href: '#/vault/' + n.id }, icon('notes', 16), h('span', { class: 'clip1' }, n.name)),
h('span', { class: 'sub' }, fmtTime(n.updated)),
trash ? [
btn('Restore', 'restore', act(async () => { await api('POST', `${VAULT}/notes/${n.id}/restore`); list(true); })),
btn('Delete for good', 'trash', act(async () => {
if (!await confirmDialog({ title: 'Delete this page for good?', body: `"${n.name}" will be deleted and cannot be brought back.`, confirm: 'Delete page' })) return;
await api('DELETE', `${VAULT}/notes/${n.id}?purge=1`); list(true);
}), 'ghost danger')] : null));
fill(root,
h('div', { class: 'page-h' }, h('div', {}, h('h1', {}, trash ? 'Vault trash' : 'Vault'),
h('p', {}, trash ? 'Deleted vault pages wait here until you delete them for good.' : 'Unlocked. These pages are not in search, the sidebar or your download.')),
h('div', { class: 'actions' },
trash ? btn('Back to the vault', 'prev', () => list(false)) : [btn('Trash', 'trash', () => list(true)), btn('Lock', 'lock', act(lock))],
trash ? null : h('button', { class: 'btn', onclick: () => { location.hash = '#/vault/new'; } }, icon('add', 16), 'New page'))),
rows.length ? h('ul', { class: 'vault-list card' }, rows)
: emptyState(trash ? 'trash' : 'lock', trash ? 'The vault trash is empty' : 'Nothing in the vault yet', trash ? 'Pages you delete from the vault appear here.' : 'Make a page and it is locked away with the rest.',
trash ? null : h('button', { class: 'btn', onclick: () => { location.hash = '#/vault/new'; } }, icon('add', 16), 'New page')),
trash && rows.length ? btn('Empty trash', 'trash', act(async () => {
if (!await confirmDialog({ title: 'Empty the vault trash?', body: 'Every page in it will be deleted and cannot be brought back.', confirm: 'Empty trash' })) return;
await api('DELETE', VAULT + '/trash'); list(true);
}), 'ghost danger') : null);
}
// one page of the vault
async function page(id) {
fill(root, skeleton());
let note = null;
if (id !== 'new') {
try { note = await api('GET', `${VAULT}/notes/${encodeURIComponent(id)}`); }
catch (e) {
if (e.status === 404) return fill(root, emptyState('notes', 'This page is not in the vault', 'It may have been deleted for good, or the link is wrong.', btn('Back to the vault', 'prev', () => { location.hash = '#/vault'; }, 'btn')));
throw e;
}
if (dead) return;
if (note.trashed) return fill(root, h('div', { class: 'notice notice-warning' }, icon('trash', 16), h('span', { class: 'grow' }, 'This page is in the vault trash.'),
btn('Restore', 'restore', act(async () => { await api('POST', `${VAULT}/notes/${note.id}/restore`); show(); }))));
}
const actions = h('div', { class: 'page-actions' }), crumbs = h('nav', { class: 'crumbs', 'aria-label': 'Where this page is' },
h('a', { href: '#/vault' }, 'Vault'));
const meta = h('p', { class: 'sub page-meta' });
ed = pageEditor({
note, vault: true,
onCreated(c) { note = c; history.replaceState(null, '', '#/vault/' + c.id); document.title = (c.name || 'New page') + ' · Vault · Mako'; draw(); },
onSaved(c) { note = c; meta.textContent = 'Edited ' + fmtTime(c.updated); document.title = (c.name || 'New page') + ' · Vault · Mako'; },
});
ed.onMode = () => draw();
view.toggleMode = () => ed.setMode(ed.mode === 'read' ? ed.lastEdit : 'read');
function draw() {
const seg = h('div', { class: 'seg', role: 'group', 'aria-label': 'How the page is shown' },
[['live', 'Live', 'Rendered, except the line you are on'], ['write', 'Edit', 'Plain markdown'], ['read', 'View', 'The finished page']].map(([m, label, tip]) =>
h('button', { class: 'seg-b' + (ed.mode === m ? ' on' : ''), 'aria-pressed': String(ed.mode === m), title: tip, onclick: () => ed.setMode(m) }, label)));
fill(actions, ed.status, h('span', { class: 'grow' }), seg, btn('Lock', 'lock', act(lock)),
btn('Delete', 'trash', act(async () => {
if (!note) { location.hash = '#/vault'; return; }
await ed.flush();
const n = note;
await api('DELETE', `${VAULT}/notes/${n.id}`);
toast('Moved to the vault trash', false, { label: 'Undo', fn: act(async () => { await api('POST', `${VAULT}/notes/${n.id}/restore`); location.hash = '#/vault/' + n.id; show(); }) });
location.hash = '#/vault';
}), 'ghost danger'));
}
const pref = store.get('mode'), start = pref === 'read' && !(note && note.body.trim()) ? 'live' : pref || 'live';
fill(root, h('div', { class: 'page-top' }, crumbs, actions),
h('div', {}, ed.notice, h('div', { class: 'title-wrap' }, ed.titleEl, ed.titleErr), ed.wrap, meta));
ed.setMode(start);
const set = ed.setMode; ed.setMode = m => { store.set('mode', m); set(m); };
if (note) meta.textContent = 'Edited ' + fmtTime(note.updated);
draw();
view.title = note?.name || 'New page';
document.title = view.title + ' · Vault · Mako';
if (!note) setTimeout(() => ed.titleEl.focus());
}
view.start = act(async () => {
fill(root, skeleton());
const v = await api('GET', VAULT);
if (dead) return;
if (!v.exists) return create();
if (!v.unlocked) return unlock();
return arg ? page(arg) : list(false);
});
return view;
}
+291
View File
@@ -0,0 +1,291 @@
package web
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"log"
"net/http"
"os"
"path/filepath"
"time"
"git.veillax.com/Veillax/mako/internal/config"
"git.veillax.com/Veillax/mako/internal/oidc"
"git.veillax.com/Veillax/mako/internal/store"
"git.veillax.com/Veillax/mako/internal/vault"
)
// The vault is a second notebook that opens only with a passphrase. Its pages are encrypted with a key
// made from that passphrase together with the server's key, so the server cannot read them until you
// unlock it, and they are in no list, search or download of the ordinary notebook. Unlocked, it lives
// in memory only until you lock it, sign out, or leave it alone for a quarter of an hour.
const (
vaultCookie = "mako_vault"
vaultIdle = 15 * time.Minute
vaultMinPass = 10
vaultTries = 5
vaultWindow = 10 * time.Minute
)
// vaultIterations is how slow it is to check one passphrase guess. Tests lower it.
var vaultIterations = 600_000
type vaultFile struct {
Salt string `json:"salt"`
Iter int `json:"iter"`
Check string `json:"check"` // a sealed word that only the right passphrase can open
}
// vaultState is an unlocked vault: one store shared by every browser holding a token for it.
type vaultState struct {
st *store.Store
tokens map[string]time.Time // cookie value to when it lapses
}
func vaultDir(c config.Config, sub string) string {
return filepath.Join(c.DataDir, "users", userKey(c, sub), "vault")
}
func readVaultFile(dir string) (vaultFile, bool) {
var vf vaultFile
b, err := os.ReadFile(filepath.Join(dir, "vault.json"))
if err != nil || json.Unmarshal(b, &vf) != nil || vf.Salt == "" || vf.Iter < 1 {
return vaultFile{}, false
}
return vf, true
}
func vaultBox(c config.Config, sub, pass string, vf vaultFile) (*vault.Cipher, error) {
salt, err := base64.RawURLEncoding.DecodeString(vf.Salt)
if err != nil {
return nil, err
}
dk, err := vault.Passphrase(pass, salt, vf.Iter)
if err != nil {
return nil, err
}
return vault.New(c.Key+"|"+dk, c.OIDC.Issuer+"|vault", sub)
}
// sweepVaults forgets tokens that have lapsed, and vaults nobody holds a token for.
func (s *Server) sweepVaults(now time.Time) {
for uk, vs := range s.vstate {
for tok, exp := range vs.tokens {
if now.After(exp) {
delete(vs.tokens, tok)
delete(s.vtoken, tok)
}
}
if len(vs.tokens) == 0 {
delete(s.vstate, uk)
}
}
}
// openVault returns the unlocked vault this browser holds the token for, and keeps it open a while longer.
func (s *Server) openVault(c config.Config, r *http.Request, sub string) (string, *store.Store, bool) {
ck, err := r.Cookie(vaultCookie)
if err != nil {
return "", nil, false
}
now := time.Now()
s.vmu.Lock()
defer s.vmu.Unlock()
s.sweepVaults(now)
uk := s.vtoken[ck.Value]
vs := s.vstate[uk]
if vs == nil || uk != userKey(c, sub) {
return "", nil, false
}
vs.tokens[ck.Value] = now.Add(vaultIdle)
return ck.Value, vs.st, true
}
// startVault hands this browser a token for the vault, opening it if no one else has.
func (s *Server) startVault(w http.ResponseWriter, c config.Config, sub string, box *vault.Cipher) error {
uk := userKey(c, sub)
s.vmu.Lock()
defer s.vmu.Unlock()
s.sweepVaults(time.Now())
vs := s.vstate[uk]
if vs == nil {
st, err := store.Open(vaultDir(c, sub), box)
if err != nil {
return err
}
vs = &vaultState{st: st, tokens: map[string]time.Time{}}
s.vstate[uk] = vs
}
tok := oidc.Random(32)
vs.tokens[tok] = time.Now().Add(vaultIdle)
s.vtoken[tok] = uk
setCookie(w, c, vaultCookie, tok, "/", vaultIdle)
return nil
}
// lockVault drops this browser's token. The vault leaves memory once nobody holds one.
func (s *Server) lockVault(w http.ResponseWriter, r *http.Request) {
c := s.cfg.Get()
if ck, err := r.Cookie(vaultCookie); err == nil {
s.vmu.Lock()
if uk, ok := s.vtoken[ck.Value]; ok {
delete(s.vtoken, ck.Value)
if vs := s.vstate[uk]; vs != nil {
delete(vs.tokens, ck.Value)
if len(vs.tokens) == 0 {
delete(s.vstate, uk)
}
}
}
s.vmu.Unlock()
}
setCookie(w, c, vaultCookie, "", "/", 0)
}
// vaultAPI is api for the pages in the vault: it needs the vault to be unlocked as well as a sign-in.
func (s *Server) vaultAPI(fn handlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
c := s.cfg.Get()
ses, ok := s.session(c, r)
if !ok {
fail(w, http.StatusUnauthorized, "sign in required")
return
}
if r.Method != http.MethodGet && r.Header.Get("X-Mako") != "1" {
fail(w, http.StatusForbidden, "missing X-Mako header")
return
}
tok, st, ok := s.openVault(c, r, ses.Sub)
if !ok {
failCode(w, http.StatusLocked, "the vault is locked", "locked")
return
}
setCookie(w, c, vaultCookie, tok, "/", vaultIdle)
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
fn(w, r, principal{Sub: ses.Sub, Name: ses.Name, Email: ses.Email, st: st})
}
}
func (s *Server) vaultInfo(w http.ResponseWriter, r *http.Request, p principal) {
c := s.cfg.Get()
_, exists := readVaultFile(vaultDir(c, p.Sub))
_, _, unlocked := s.openVault(c, r, p.Sub)
writeJSON(w, map[string]bool{"exists": exists, "unlocked": unlocked})
}
type passphraseBody struct {
Passphrase string `json:"passphrase"`
}
func (s *Server) vaultCreate(w http.ResponseWriter, r *http.Request, p principal) {
var in passphraseBody
if !decode(w, r, &in) {
return
}
c := s.cfg.Get()
dir := vaultDir(c, p.Sub)
if _, exists := readVaultFile(dir); exists {
fail(w, http.StatusConflict, "you already have a vault; unlock it instead")
return
}
if len([]rune(in.Passphrase)) < vaultMinPass {
fail(w, http.StatusBadRequest, "pick a passphrase of at least 10 characters; a few words strung together is good")
return
}
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
fail(w, http.StatusInternalServerError, "could not make a vault")
return
}
vf := vaultFile{Salt: base64.RawURLEncoding.EncodeToString(salt), Iter: vaultIterations}
box, err := vaultBox(c, p.Sub, in.Passphrase, vf)
if err != nil {
fail(w, http.StatusInternalServerError, "could not make a vault")
return
}
vf.Check = base64.RawURLEncoding.EncodeToString(box.Seal([]byte("mako vault"), "check"))
b, _ := json.Marshal(vf)
if err := os.MkdirAll(dir, 0o700); err != nil {
log.Printf("vault: %v", err)
fail(w, http.StatusInternalServerError, "could not make a vault")
return
}
tmp := filepath.Join(dir, ".vault.json.tmp")
err = os.WriteFile(tmp, b, 0o600)
if err == nil {
err = os.Rename(tmp, filepath.Join(dir, "vault.json"))
}
if err != nil {
log.Printf("vault: %v", err)
fail(w, http.StatusInternalServerError, "could not make a vault")
return
}
if err := s.startVault(w, c, p.Sub, box); err != nil {
log.Printf("vault: %v", err)
fail(w, http.StatusInternalServerError, "could not open the vault; the server's log says why")
return
}
writeJSON(w, map[string]bool{"exists": true, "unlocked": true})
}
func (s *Server) vaultUnlock(w http.ResponseWriter, r *http.Request, p principal) {
var in passphraseBody
if !decode(w, r, &in) {
return
}
c := s.cfg.Get()
vf, exists := readVaultFile(vaultDir(c, p.Sub))
if !exists {
fail(w, http.StatusNotFound, "you have no vault yet")
return
}
uk, now := userKey(c, p.Sub), time.Now()
s.vmu.Lock()
recent := s.vfails[uk][:0]
for _, t := range s.vfails[uk] {
if now.Sub(t) < vaultWindow {
recent = append(recent, t)
}
}
s.vfails[uk] = recent
tooMany := len(recent) >= vaultTries
s.vmu.Unlock()
if tooMany {
failCode(w, http.StatusTooManyRequests, "too many wrong passphrases; try again in a few minutes", "too_many")
return
}
box, err := vaultBox(c, p.Sub, in.Passphrase, vf)
check, _ := base64.RawURLEncoding.DecodeString(vf.Check)
var word []byte
if err == nil {
word, err = box.Open(check, "check")
}
if err != nil || subtle.ConstantTimeCompare(word, []byte("mako vault")) != 1 {
s.vmu.Lock()
s.vfails[uk] = append(s.vfails[uk], now)
s.vmu.Unlock()
failCode(w, http.StatusForbidden, "that is not the passphrase", "wrong_passphrase")
return
}
s.vmu.Lock()
delete(s.vfails, uk)
s.vmu.Unlock()
if err := s.startVault(w, c, p.Sub, box); err != nil {
log.Printf("vault: %v", err)
if errors.Is(err, vault.ErrWrongKey) {
fail(w, http.StatusInternalServerError, "the vault's pages cannot be read with this key; the server's log says more")
} else {
fail(w, http.StatusInternalServerError, "could not open the vault; the server's log says why")
}
return
}
writeJSON(w, map[string]bool{"exists": true, "unlocked": true})
}
func (s *Server) vaultLock(w http.ResponseWriter, r *http.Request, p principal) {
s.lockVault(w, r)
writeJSON(w, map[string]bool{"exists": true, "unlocked": false})
}
+157
View File
@@ -677,3 +677,160 @@ func TestKeyIsMadeOnceAndKept(t *testing.T) {
t.Fatalf("a short key was accepted: %v", err)
}
}
// ---- the vault ----
func withVault(t *testing.T) *env {
t.Helper()
old := vaultIterations
vaultIterations = 1000
t.Cleanup(func() { vaultIterations = old })
return setup(t, nil)
}
// vaultCookies returns the sign-in cookies plus the vault token from a response.
func vaultCookies(ses []*http.Cookie, w *httptest.ResponseRecorder) []*http.Cookie {
if v := cookieNamed(w, vaultCookie); v != nil && v.Value != "" {
return append(append([]*http.Cookie{}, ses...), v)
}
return ses
}
func TestVaultLifecycle(t *testing.T) {
e := withVault(t)
a := e.signIn(alice)
var info struct{ Exists, Unlocked bool }
jsonOf(t, e.do("GET", "/api/vault", nil, a), &info)
if info.Exists || info.Unlocked {
t.Fatalf("a new person already has a vault: %+v", info)
}
if w := e.do("GET", "/api/vault/notes", nil, a); w.Code != http.StatusLocked {
t.Fatalf("a vault that does not exist is not locked: %d", w.Code)
}
if w := e.do("POST", "/api/vault", map[string]string{"passphrase": "short"}, a); w.Code != http.StatusBadRequest {
t.Fatalf("a short passphrase: %d", w.Code)
}
w := e.do("POST", "/api/vault", map[string]string{"passphrase": "correct horse battery"}, a)
if w.Code != http.StatusOK {
t.Fatalf("create: %d %s", w.Code, w.Body)
}
tok := cookieNamed(w, vaultCookie)
if tok == nil || !tok.HttpOnly || tok.Value == "" {
t.Fatalf("vault cookie: %+v", tok)
}
open := vaultCookies(a, w)
// a page in the vault
var n struct{ ID string }
if w := e.do("POST", "/api/vault/notes", map[string]string{"title": "Bank codes", "body": "hunter2 and the safe number"}, open); w.Code != http.StatusOK && w.Code != http.StatusCreated {
t.Fatalf("create page: %d %s", w.Code, w.Body)
} else {
jsonOf(t, w, &n)
}
if w := e.do("GET", "/api/vault/notes/"+n.ID, nil, open); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "hunter2") {
t.Fatalf("read page: %d %s", w.Code, w.Body)
}
// it is nowhere in the ordinary notebook
for _, path := range []string{"/api/notes", "/api/pages", "/api/find?q=hunter2", "/api/find?q=Bank"} {
if w := e.do("GET", path, nil, open); strings.Contains(w.Body.String(), "hunter2") || strings.Contains(w.Body.String(), "Bank codes") {
t.Fatalf("%s shows the vault: %s", path, w.Body)
}
}
if w := e.do("GET", "/api/notes/"+n.ID, nil, open); w.Code != http.StatusNotFound {
t.Fatalf("a vault page opens as an ordinary one: %d", w.Code)
}
if w := e.do("GET", "/api/export", nil, open); bytes.Contains(w.Body.Bytes(), []byte("hunter2")) {
t.Fatal("the vault is in the download")
}
var found struct{ Hits []struct{ ID string } }
jsonOf(t, e.do("GET", "/api/vault/find?q=safe", nil, open), &found)
if len(found.Hits) != 1 {
t.Fatalf("vault search: %+v", found)
}
// locking, and the old cookie no longer opens anything
if w := e.do("POST", "/api/vault/lock", nil, open); w.Code != http.StatusOK {
t.Fatalf("lock: %d", w.Code)
}
if w := e.do("GET", "/api/vault/notes/"+n.ID, nil, open); w.Code != http.StatusLocked {
t.Fatalf("the vault opened after locking: %d", w.Code)
}
// the wrong passphrase, then the right one
if w := e.do("POST", "/api/vault/unlock", map[string]string{"passphrase": "wrong horse battery"}, a); w.Code != http.StatusForbidden {
t.Fatalf("wrong passphrase: %d", w.Code)
}
w = e.do("POST", "/api/vault/unlock", map[string]string{"passphrase": "correct horse battery"}, a)
if w.Code != http.StatusOK {
t.Fatalf("unlock: %d %s", w.Code, w.Body)
}
if w := e.do("GET", "/api/vault/notes/"+n.ID, nil, vaultCookies(a, w)); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "hunter2") {
t.Fatalf("read after unlock: %d", w.Code)
}
if w := e.do("POST", "/api/vault", map[string]string{"passphrase": "another passphrase"}, a); w.Code != http.StatusConflict {
t.Fatalf("a second vault: %d", w.Code)
}
}
func TestVaultIsEncryptedOnDiskAndPrivate(t *testing.T) {
e := withVault(t)
a, b := e.signIn(alice), e.signIn(bob)
w := e.do("POST", "/api/vault", map[string]string{"passphrase": "correct horse battery"}, a)
open := vaultCookies(a, w)
e.do("POST", "/api/vault/notes", map[string]string{"title": "Moonlight plan", "body": "tell nobody"}, open)
filepath.WalkDir(filepath.Join(e.dir, "data"), func(p string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
raw, _ := os.ReadFile(p)
for _, s := range []string{"Moonlight", "tell nobody", "correct horse"} {
if bytes.Contains(raw, []byte(s)) {
t.Errorf("%s holds %q in the clear", p, s)
}
}
return nil
})
// Bob cannot use Alice's vault cookie, and has no vault of his own
stolen := append(append([]*http.Cookie{}, b...), cookieNamed(w, vaultCookie))
if w := e.do("GET", "/api/vault/notes", nil, stolen); w.Code != http.StatusLocked {
t.Fatalf("Bob opened Alice's vault with her cookie: %d", w.Code)
}
if w := e.do("POST", "/api/vault/unlock", map[string]string{"passphrase": "correct horse battery"}, b); w.Code != http.StatusNotFound {
t.Fatalf("Bob unlocking a vault he does not have: %d", w.Code)
}
// signing out locks it
e.do("POST", "/api/logout", nil, open)
if w := e.do("GET", "/api/vault/notes", nil, open); w.Code == http.StatusOK {
t.Fatal("the vault stayed open after signing out")
}
}
func TestVaultGuessingIsLimited(t *testing.T) {
e := withVault(t)
a := e.signIn(alice)
e.do("POST", "/api/vault", map[string]string{"passphrase": "correct horse battery"}, a)
e.do("POST", "/api/vault/lock", nil, a)
for i := 0; i < vaultTries; i++ {
if w := e.do("POST", "/api/vault/unlock", map[string]string{"passphrase": fmt.Sprintf("guess number %d", i)}, a); w.Code != http.StatusForbidden {
t.Fatalf("guess %d: %d", i, w.Code)
}
}
if w := e.do("POST", "/api/vault/unlock", map[string]string{"passphrase": "correct horse battery"}, a); w.Code != http.StatusTooManyRequests {
t.Fatalf("even the right passphrase is held back after too many guesses: %d", w.Code)
}
}
func TestVaultNeedsSignInAndHeader(t *testing.T) {
e := withVault(t)
if w := e.do("GET", "/api/vault", nil, nil); w.Code != http.StatusUnauthorized {
t.Fatalf("no sign-in: %d", w.Code)
}
a := e.signIn(alice)
if w := e.do("POST", "/api/vault", map[string]string{"passphrase": "correct horse battery"}, a, "X-Mako", ""); w.Code != http.StatusForbidden {
t.Fatalf("no header: %d", w.Code)
}
}