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 @@
+