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

+11
View File
@@ -33,6 +33,10 @@ Named for the mako shark, the fastest in the sea, because a notebook should keep
keeps your text and retries. If your Authentik session ends mid-sentence, it keeps the text, offers
to sign in again in another tab, and saves when you are back. If a page changed on another device,
it asks which version to keep instead of overwriting.
- **A vault for what needs more.** Pages in the Vault open only with a passphrase of their own, on top of
signing in. The server cannot read them until you unlock it, and they are in no list, search or download
of your ordinary notebook. It locks itself after 15 minutes idle, when you press Lock, and when you
sign out. See Encryption below.
- **Take it with you.** Download everything as a zip that opens as a vault in Obsidian: file names are
titles, so the links keep working, and journal days sit in a Journal folder.
@@ -139,6 +143,11 @@ markdown.
- **What it protects:** a stolen disk or backup of `data/`, and anyone who can read the notes folder but
not the settings. **What it does not:** someone who controls the running server, or who can read both
`mako.json` and `data/`. Who you are in Authentik is not secret, so it cannot be the key by itself.
- **The Vault** adds a second lock. Its pages are encrypted with a key made from your passphrase
(PBKDF2, 600,000 rounds) together with the server's key, so someone with the server's key still cannot
read them without the passphrase. The server holds the unlocked key in memory only, for as long as the
vault stays open. **There is no way to reset the passphrase**; forget it and those pages are gone.
Five wrong guesses in ten minutes lock out further tries for a while.
- **Upgrading from 0.1.0:** existing plain files are read and saved again encrypted the first time
that person signs in, and a `key` is added to your settings file.
@@ -191,6 +200,8 @@ file time as its date, and is then encrypted (name it with lower-case letters, n
- Searching holds every page in memory, which is comfortable for tens of thousands of pages and not
built for millions.
- Pages are only reloaded from disk when Mako starts.
- The Vault has its own pages only: no links into or out of it, no tags, no journal, and it is not in the
zip download. You cannot change its passphrase yet.
- Live preview works a line at a time (a code block counts as one), so a table or a quote spread over
several lines shows rendered line by line. View shows the whole page as one. Selecting across lines
(Ctrl+A, Shift+Up or Down, Shift+click, or dragging) opens those whole lines as raw markdown, so a
+9
View File
@@ -8,8 +8,10 @@ import (
"crypto/aes"
"crypto/cipher"
"crypto/hkdf"
"crypto/pbkdf2"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
)
@@ -50,6 +52,13 @@ func New(master, issuer, sub string) (*Cipher, error) {
return &Cipher{aead}, nil
}
// Passphrase stretches a passphrase into key material (hex) with PBKDF2-HMAC-SHA256, which is slow on
// purpose so that guessing passphrases is expensive.
func Passphrase(pass string, salt []byte, iterations int) (string, error) {
k, err := pbkdf2.Key(sha256.New, pass, salt, iterations, 32)
return hex.EncodeToString(k), err
}
// IsSealed says whether data is an encrypted file.
func IsSealed(data []byte) bool { return bytes.HasPrefix(data, magic) }
+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)
}
}