diff --git a/README.md b/README.md index 44b8bb1..c2c756f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/internal/vault/vault.go b/internal/vault/vault.go index 5468ba9..89f2bde 100644 --- a/internal/vault/vault.go +++ b/internal/vault/vault.go @@ -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) } diff --git a/internal/web/auth.go b/internal/web/auth.go index 7cd7327..8c3f344 100644 --- a/internal/web/auth.go +++ b/internal/web/auth.go @@ -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}) } diff --git a/internal/web/server.go b/internal/web/server.go index a45688b..cb53467 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -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) diff --git a/internal/web/static/app.js b/internal/web/static/app.js index fbae6e3..56122cb 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -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.' }); } diff --git a/internal/web/static/editor.js b/internal/web/static/editor.js index d097dce..96eee8f 100644 --- a/internal/web/static/editor.js +++ b/internal/web/static/editor.js @@ -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]' : '[ ]'); diff --git a/internal/web/static/index.html b/internal/web/static/index.html index bb13589..1daab6f 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -13,6 +13,7 @@ + diff --git a/internal/web/static/markdown.js b/internal/web/static/markdown.js index b85988a..12f0c38 100644 --- a/internal/web/static/markdown.js +++ b/internal/web/static/markdown.js @@ -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; diff --git a/internal/web/static/style.css b/internal/web/static/style.css index 063740f..8a805f3 100644 --- a/internal/web/static/style.css +++ b/internal/web/static/style.css @@ -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; } diff --git a/internal/web/static/vault.js b/internal/web/static/vault.js new file mode 100644 index 0000000..9a57839 --- /dev/null +++ b/internal/web/static/vault.js @@ -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; +} diff --git a/internal/web/vault.go b/internal/web/vault.go new file mode 100644 index 0000000..4d23a30 --- /dev/null +++ b/internal/web/vault.go @@ -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}) +} diff --git a/internal/web/web_test.go b/internal/web/web_test.go index 79282dc..a1f9f74 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -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) + } +}