Encrypt pages at rest with a key per person
CI / test (push) Successful in 2m2s
Release / release (push) Successful in 2m15s

Pages are sealed with AES-256-GCM. Each person's key is derived (HKDF) from a master key in the settings file plus their Authentik issuer and subject. Plain files from earlier versions are read and re-saved encrypted. Mako refuses to open a notebook it cannot decrypt rather than overwrite it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-11-01 00:25:37 -06:00
1 parent e3147597e8
commit 9f2fe4a416
11 files changed
+396 -33

No files matched your search

+28 -8
View File
@@ -2,7 +2,7 @@
A private notebook in the spirit of Obsidian and Notion, for people who would rather run their own. A private notebook in the spirit of Obsidian and Notion, for people who would rather run their own.
Pages that nest inside each other, `[[links]]` between them with backlinks, a journal page for each Pages that nest inside each other, `[[links]]` between them with backlinks, a journal page for each
day, tags, and quick search, all kept as plain markdown files. One small binary, no database, no day, tags, and quick search, all kept as encrypted markdown files. One small binary, no database, no
dependencies. You sign in with **Authentik** and nothing else: there are no passwords in Mako. dependencies. You sign in with **Authentik** and nothing else: there are no passwords in Mako.
Named for the mako shark, the fastest in the sea, because a notebook should keep up with you. Named for the mako shark, the fastest in the sea, because a notebook should keep up with you.
@@ -116,23 +116,42 @@ written to it:
| `oidc.client_id` | `MAKO_OIDC_CLIENT_ID` | | | `oidc.client_id` | `MAKO_OIDC_CLIENT_ID` | |
| `oidc.client_secret` | `MAKO_OIDC_CLIENT_SECRET` | | | `oidc.client_secret` | `MAKO_OIDC_CLIENT_SECRET` | |
| `allowed` | `MAKO_ALLOWED` | optional; emails, usernames or Authentik ids, comma separated | | `allowed` | `MAKO_ALLOWED` | optional; emails, usernames or Authentik ids, comma separated |
| `key` | `MAKO_KEY` | encrypts your pages; made for you, see Encryption below |
| `listen` | `MAKO_LISTEN` | default `127.0.0.1:8490` | | `listen` | `MAKO_LISTEN` | default `127.0.0.1:8490` |
| `data_dir` | `MAKO_DATA` | default `data` | | `data_dir` | `MAKO_DATA` | default `data` |
`mako.json` also holds a `secret` that signs session cookies. It is made on first run and written `mako.json` also holds a `secret` that signs session cookies. It is made on first run and written
with mode `0600`. Changing it signs everyone out. with mode `0600`. Changing it signs everyone out.
## Encryption
Every page is encrypted on disk with AES-256-GCM. Each person has a key of their own, made from the
`key` in `mako.json` together with who they are in Authentik (the issuer and their subject id). So one
person's files cannot be opened with another's key, and a file copied over another page's name stops
opening. Pages are decrypted in the server's memory while it runs, and your downloads are plain
markdown.
- **The key is made on first run** and written to `mako.json` (mode `0600`), or set `MAKO_KEY` (at least
32 characters, for example `openssl rand -base64 32`) to keep it out of the file.
- **Back the key up, apart from the notes.** Without it the pages cannot be read, and Mako refuses to
start a notebook it cannot decrypt rather than overwrite it. Do not change it. Unlike `secret`,
changing `key` does not just sign people out.
- **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.
- **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.
## Where your notes are ## Where your notes are
``` ```
data/users/<hash of who you are>/ data/users/<hash of who you are>/
owner.txt your name and email, so the folder can be matched to a person notes/20261031-143205-a1b2c3 one encrypted file per page (named .md)
notes/20261031-143205-a1b2c3.md one file per page notes/daily-2026-10-31 the journal page for a day
notes/daily-2026-10-31.md the journal page for a day
trash/ deleted pages, until the trash is emptied trash/ deleted pages, until the trash is emptied
``` ```
A page file looks like this, so you can edit it with anything: Inside, a page is markdown with a small header (shown here decrypted, and what the zip export holds):
``` ```
--- ---
@@ -146,8 +165,8 @@ pinned: true
Flights and hotels, see [[Packing list]] #travel Flights and hotels, see [[Packing list]] #travel
``` ```
A plain `.md` file dropped into `notes/` is picked up too, with its file time as its date (name it with A plain `.md` file dropped into `notes/` is picked up when that person's notebook next opens, with its
lower-case letters, numbers and dashes). Restart Mako to see files added by hand. file time as its date, and is then encrypted (name it with lower-case letters, numbers and dashes).
## Security notes ## Security notes
@@ -161,7 +180,8 @@ lower-case letters, numbers and dashes). Restart Mako to see files added by hand
person's requests can only ever reach their own folder. person's requests can only ever reach their own folder.
- Every change from the browser needs a header a page on another site can't send. The page has a strict - Every change from the browser needs a header a page on another site can't send. The page has a strict
Content-Security-Policy, and pages are never rendered as HTML. Content-Security-Policy, and pages are never rendered as HTML.
- Pages are **not encrypted** on disk. Anyone who can read the server's disk can read them. - Pages are encrypted on disk (see Encryption), with a key per person. The server itself, while it
runs, can read them: this is not end-to-end encryption.
## Honest limits ## Honest limits
+1 -1
View File
@@ -99,7 +99,7 @@ func serve(args []string) error {
if err != nil { if err != nil {
return configError{err} return configError{err}
} }
if !h.Exists() { // writing it now is what makes the generated secret stick if h.NeedsSave() { // writing it now is what makes the generated secret and key stick
if err := h.Save(); err != nil { if err := h.Save(); err != nil {
return fmt.Errorf("could not write %s: %w", *path, err) return fmt.Errorf("could not write %s: %w", *path, err)
} }
+1 -1
View File
@@ -14,4 +14,4 @@ services:
MAKO_OIDC_CLIENT_SECRET: change-me MAKO_OIDC_CLIENT_SECRET: change-me
# MAKO_ALLOWED: you@example.com # optional: limit who may sign in # MAKO_ALLOWED: you@example.com # optional: limit who may sign in
volumes: volumes:
- ./config:/config # mako.json (the signing secret) and data/ (your pages) live here - ./config:/config # mako.json (signing secret and note key; back it up apart from data/) and data/ live here
+2 -1
View File
@@ -155,7 +155,8 @@ MAKO_OIDC_CLIENT_SECRET=
# everyone Authentik lets into the application gets a private notebook of their own. # everyone Authentik lets into the application gets a private notebook of their own.
#MAKO_ALLOWED=you@example.com #MAKO_ALLOWED=you@example.com
# Where the settings file is (it holds the signing secret). # Where the settings file is. It holds the signing secret and the key that encrypts your notes:
# back it up, apart from $DATA_DIR. Without that key the notes cannot be read.
MAKO_CONFIG=$CONF_FILE MAKO_CONFIG=$CONF_FILE
# Optional. These override what the settings file says. # Optional. These override what the settings file says.
+27
View File
@@ -13,6 +13,8 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"sync" "sync"
"git.veillax.com/Veillax/mako/internal/vault"
) )
// OIDC is the OAuth client Authentik gives you for Mako. // OIDC is the OAuth client Authentik gives you for Mako.
@@ -37,6 +39,10 @@ type Config struct {
// Secret signs session cookies, so a restart does not sign you out. It is made on first run. // Secret signs session cookies, so a restart does not sign you out. It is made on first run.
Secret string `json:"secret,omitempty"` Secret string `json:"secret,omitempty"`
// Key encrypts the pages on disk (each person's key is made from it and who they are). It is made
// on first run. Unlike Secret it must never change or be lost: without it the pages cannot be read.
Key string `json:"key,omitempty"`
} }
// Defaults fills in what is missing. // Defaults fills in what is missing.
@@ -52,6 +58,11 @@ func (c *Config) Defaults() {
rand.Read(b) rand.Read(b)
c.Secret = base64.RawURLEncoding.EncodeToString(b) c.Secret = base64.RawURLEncoding.EncodeToString(b)
} }
if c.Key == "" {
b := make([]byte, 32)
rand.Read(b)
c.Key = base64.RawURLEncoding.EncodeToString(b)
}
c.PublicURL = strings.TrimRight(strings.TrimSpace(c.PublicURL), "/") c.PublicURL = strings.TrimRight(strings.TrimSpace(c.PublicURL), "/")
c.OIDC.Issuer = strings.TrimSpace(c.OIDC.Issuer) c.OIDC.Issuer = strings.TrimSpace(c.OIDC.Issuer)
c.OIDC.ClientID = strings.TrimSpace(c.OIDC.ClientID) c.OIDC.ClientID = strings.TrimSpace(c.OIDC.ClientID)
@@ -83,6 +94,9 @@ func checkURL(name, raw string) error {
// Check says what is still missing before Mako can sign anyone in, in words a person can act on. // Check says what is still missing before Mako can sign anyone in, in words a person can act on.
func (c Config) Check() error { func (c Config) Check() error {
if len(c.Key) < vault.MinKey {
return fmt.Errorf("the encryption key (key, or MAKO_KEY) must be at least %d characters; leave it out to have one made, or make one with: openssl rand -base64 32", vault.MinKey)
}
var missing []string var missing []string
if c.PublicURL == "" { if c.PublicURL == "" {
missing = append(missing, " public_url (MAKO_PUBLIC_URL) where you open Mako, such as https://notes.example.com") missing = append(missing, " public_url (MAKO_PUBLIC_URL) where you open Mako, such as https://notes.example.com")
@@ -121,6 +135,7 @@ type Holder struct {
path string path string
mu sync.RWMutex mu sync.RWMutex
file Config file Config
made bool // the file is missing, or lacked a secret or key that was just made
} }
// Load reads the file. A missing file gives the defaults (and is created by the first Update). // Load reads the file. A missing file gives the defaults (and is created by the first Update).
@@ -136,10 +151,21 @@ func Load(path string) (*Holder, error) {
return nil, fmt.Errorf("%s: %w", path, err) return nil, fmt.Errorf("%s: %w", path, err)
} }
} }
h.made = err != nil || h.file.Secret == "" || (h.file.Key == "" && !keyInEnv())
keyless := h.file.Key == "" && keyInEnv() // a key in the environment is used instead, so none is made
h.file.Defaults() h.file.Defaults()
if keyless {
h.file.Key = ""
}
return h, nil return h, nil
} }
func keyInEnv() bool { return strings.TrimSpace(os.Getenv("MAKO_KEY")) != "" }
// NeedsSave says whether the file should be written now: it is missing, or a secret or key was
// made that is only in memory. Writing it is what makes them stick.
func (h *Holder) NeedsSave() bool { return h.made }
func (h *Holder) Path() string { return h.path } func (h *Holder) Path() string { return h.path }
// Exists says whether the file is on disk yet. // Exists says whether the file is on disk yet.
@@ -162,6 +188,7 @@ func (h *Holder) Get() Config {
env("MAKO_OIDC_ISSUER", &c.OIDC.Issuer) env("MAKO_OIDC_ISSUER", &c.OIDC.Issuer)
env("MAKO_OIDC_CLIENT_ID", &c.OIDC.ClientID) env("MAKO_OIDC_CLIENT_ID", &c.OIDC.ClientID)
env("MAKO_OIDC_CLIENT_SECRET", &c.OIDC.ClientSecret) env("MAKO_OIDC_CLIENT_SECRET", &c.OIDC.ClientSecret)
env("MAKO_KEY", &c.Key)
if v := os.Getenv("MAKO_ALLOWED"); strings.TrimSpace(v) != "" { if v := os.Getenv("MAKO_ALLOWED"); strings.TrimSpace(v) != "" {
c.Allowed = nil c.Allowed = nil
for _, a := range strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == ';' || r == '\n' }) { for _, a := range strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == ';' || r == '\n' }) {
+29 -7
View File
@@ -1,5 +1,5 @@
// Package store keeps pages as plain markdown files, one per page, so they stay readable and // Package store keeps pages as markdown files, one per page, encrypted at rest (see package vault)
// portable with or without Mako. An in-memory index makes listing, searching and linking quick. // and exportable as plain markdown. An in-memory index makes listing, searching and linking quick.
package store package store
import ( import (
@@ -20,6 +20,8 @@ import (
"time" "time"
"unicode" "unicode"
"unicode/utf8" "unicode/utf8"
"git.veillax.com/Veillax/mako/internal/vault"
) )
// MaxBody is the longest page, in bytes. MaxTitle is the longest title, in characters. // MaxBody is the longest page, in bytes. MaxTitle is the longest title, in characters.
@@ -60,6 +62,7 @@ type Note struct {
type Store struct { type Store struct {
dir string dir string
box *vault.Cipher
mu sync.RWMutex mu sync.RWMutex
by map[string]*Note by map[string]*Note
} }
@@ -72,11 +75,16 @@ var (
// ValidID says whether s can be used as a page id (and so as a file name). // ValidID says whether s can be used as a page id (and so as a file name).
func ValidID(s string) bool { return idRe.MatchString(s) } func ValidID(s string) bool { return idRe.MatchString(s) }
// Open reads every page under dir, creating the folders if needed. // Open reads every page under dir, creating the folders if needed. Pages are encrypted with box. A
func Open(dir string) (*Store, error) { // plain markdown file found there is still read, and is saved again encrypted.
s := &Store{dir: dir, by: map[string]*Note{}} func Open(dir string, box *vault.Cipher) (*Store, error) {
if box == nil {
return nil, errors.New("a notebook needs an encryption key")
}
s := &Store{dir: dir, box: box, by: map[string]*Note{}}
var plain []*Note
for _, sub := range []string{"notes", "trash"} { for _, sub := range []string{"notes", "trash"} {
if err := os.MkdirAll(filepath.Join(dir, sub), 0o755); err != nil { if err := os.MkdirAll(filepath.Join(dir, sub), 0o700); err != nil {
return nil, err return nil, err
} }
entries, err := os.ReadDir(filepath.Join(dir, sub)) entries, err := os.ReadDir(filepath.Join(dir, sub))
@@ -97,12 +105,26 @@ func Open(dir string) (*Store, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
sealed := vault.IsSealed(b)
if sealed {
if b, err = box.Open(b, id); err != nil {
return nil, fmt.Errorf("%s: %w; put the original key back (key in the settings file, or MAKO_KEY)", path, err)
}
}
n := parse(id, string(b), info.ModTime()) n := parse(id, string(b), info.ModTime())
n.Trashed = sub == "trash" n.Trashed = sub == "trash"
s.by[id] = n s.by[id] = n
if !sealed {
plain = append(plain, n)
}
} }
} }
s.mend() s.mend()
for _, n := range plain {
if err := s.write(n); err != nil {
return nil, err
}
}
return s, nil return s, nil
} }
@@ -339,7 +361,7 @@ func isNumber(s string) bool {
func (s *Store) write(n *Note) error { func (s *Store) write(n *Note) error {
path := s.path(n) path := s.path(n)
tmp := filepath.Join(filepath.Dir(path), "."+n.ID+".tmp") tmp := filepath.Join(filepath.Dir(path), "."+n.ID+".tmp")
if err := os.WriteFile(tmp, format(n), 0o644); err != nil { if err := os.WriteFile(tmp, s.box.Seal(format(n), n.ID), 0o600); err != nil {
return err return err
} }
return os.Rename(tmp, path) return os.Rename(tmp, path)
+82 -8
View File
@@ -10,12 +10,14 @@ import (
"strings" "strings"
"testing" "testing"
"time" "time"
"git.veillax.com/Veillax/mako/internal/vault"
) )
func open(t *testing.T) (*Store, string) { func open(t *testing.T) (*Store, string) {
t.Helper() t.Helper()
dir := t.TempDir() dir := t.TempDir()
s, err := Open(dir) s, err := Open(dir, testBox(t))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -100,7 +102,7 @@ func TestCreateRoundTripsThroughDisk(t *testing.T) {
if _, err := os.Stat(filepath.Join(dir, "notes", n.ID+".md")); err != nil { if _, err := os.Stat(filepath.Join(dir, "notes", n.ID+".md")); err != nil {
t.Fatal(err) t.Fatal(err)
} }
s2, err := Open(dir) s2, err := Open(dir, testBox(t))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -126,7 +128,7 @@ func TestPlainMarkdownFileIsAPage(t *testing.T) {
os.WriteFile(path, []byte("# Dropped in\r\nby hand #manual\r\n"), 0o644) os.WriteFile(path, []byte("# Dropped in\r\nby hand #manual\r\n"), 0o644)
mod := time.Date(2025, 3, 4, 5, 6, 7, 0, time.UTC) mod := time.Date(2025, 3, 4, 5, 6, 7, 0, time.UTC)
os.Chtimes(path, mod, mod) os.Chtimes(path, mod, mod)
s2, err := Open(dir) s2, err := Open(dir, testBox(t))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -209,7 +211,7 @@ func TestRenameRewritesLinks(t *testing.T) {
if other, _ := s.Get(b.ID); other.Body != b.Body || !other.Updated.Equal(b.Updated) { if other, _ := s.Get(b.ID); other.Body != b.Body || !other.Updated.Equal(b.Updated) {
t.Fatal("unrelated pages must not be touched") t.Fatal("unrelated pages must not be touched")
} }
s2, _ := Open(s.dir) s2, _ := Open(s.dir, testBox(t))
if again, _ := s2.Get(a.ID); again.Body != want { if again, _ := s2.Get(a.ID); again.Body != want {
t.Fatal("rewrite was not saved") t.Fatal("rewrite was not saved")
} }
@@ -255,7 +257,7 @@ func TestTreeParentsAndCycles(t *testing.T) {
os.WriteFile(filepath.Join(dir, "notes", "x1.md"), []byte("---\nparent: x2\n---\none"), 0o644) os.WriteFile(filepath.Join(dir, "notes", "x1.md"), []byte("---\nparent: x2\n---\none"), 0o644)
os.WriteFile(filepath.Join(dir, "notes", "x2.md"), []byte("---\nparent: x1\n---\ntwo"), 0o644) os.WriteFile(filepath.Join(dir, "notes", "x2.md"), []byte("---\nparent: x1\n---\ntwo"), 0o644)
os.WriteFile(filepath.Join(dir, "notes", "x3.md"), []byte("---\nparent: ghost\n---\nthree"), 0o644) os.WriteFile(filepath.Join(dir, "notes", "x3.md"), []byte("---\nparent: ghost\n---\nthree"), 0o644)
s2, err := Open(dir) s2, err := Open(dir, testBox(t))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -299,7 +301,7 @@ func TestTrashRestorePurge(t *testing.T) {
if _, err := s.Create(NewNote{Title: "Child"}); err != nil { if _, err := s.Create(NewNote{Title: "Child"}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
s2, _ := Open(dir) s2, _ := Open(dir, testBox(t))
back, err := s2.Restore(n.ID) back, err := s2.Restore(n.ID)
if err != nil || back.Title != "Child 2" || back.Parent != parent.ID { if err != nil || back.Title != "Child 2" || back.Parent != parent.ID {
t.Fatalf("restore: %v %+v", err, back) t.Fatalf("restore: %v %+v", err, back)
@@ -355,7 +357,7 @@ func TestDaily(t *testing.T) {
t.Fatalf("a day can be linked by its date: %+v", got) t.Fatalf("a day can be linked by its date: %+v", got)
} }
// reload keeps the title from the date, and an empty day stays out of navigation // reload keeps the title from the date, and an empty day stays out of navigation
s2, _ := Open(s.dir) s2, _ := Open(s.dir, testBox(t))
if n, _ := s2.Get(d1.ID); n.Title != "2026-10-31" { if n, _ := s2.Get(d1.ID); n.Title != "2026-10-31" {
t.Fatalf("title after reload: %q", n.Title) t.Fatalf("title after reload: %q", n.Title)
} }
@@ -465,7 +467,7 @@ func TestIDsCannotEscape(t *testing.T) {
} }
_, dir := open(t) _, dir := open(t)
os.WriteFile(filepath.Join(dir, "notes", "Bad Name.md"), []byte("ignored"), 0o644) os.WriteFile(filepath.Join(dir, "notes", "Bad Name.md"), []byte("ignored"), 0o644)
s2, _ := Open(dir) s2, _ := Open(dir, testBox(t))
if list, _ := s2.List(Query{}); len(list) != 0 { if list, _ := s2.List(Query{}); len(list) != 0 {
t.Fatal("a file whose name is not a valid id should be ignored") t.Fatal("a file whose name is not a valid id should be ignored")
} }
@@ -502,3 +504,75 @@ func TestZipIsAnObsidianVault(t *testing.T) {
} }
} }
} }
func testBox(t *testing.T) *vault.Cipher {
t.Helper()
b, err := vault.New("0123456789abcdef0123456789abcdef", "https://auth.example.com/", "alice")
if err != nil {
t.Fatal(err)
}
return b
}
func TestFilesAreEncrypted(t *testing.T) {
s, dir := open(t)
n := mk(t, s, "Diary", "meet at the #harbour")
b, err := os.ReadFile(filepath.Join(dir, "notes", n.ID+".md"))
if err != nil {
t.Fatal(err)
}
if !vault.IsSealed(b) || bytes.Contains(b, []byte("harbour")) || bytes.Contains(b, []byte("Diary")) {
t.Fatalf("the page is on disk in the clear: %q", b)
}
// trashing keeps it encrypted, and everything reads back
if _, err := s.Trash(n.ID); err != nil {
t.Fatal(err)
}
b, _ = os.ReadFile(filepath.Join(dir, "trash", n.ID+".md"))
if !vault.IsSealed(b) {
t.Fatalf("a trashed page is in the clear: %q", b)
}
s2, err := Open(dir, testBox(t))
if err != nil {
t.Fatal(err)
}
if got, err := s2.Get(n.ID); err != nil || got.Body != "meet at the #harbour" || !got.Trashed {
t.Fatalf("reload: %+v %v", got, err)
}
}
func TestPlainFilesAreEncryptedWhenOpened(t *testing.T) {
_, dir := open(t)
path := filepath.Join(dir, "notes", "old-page.md")
os.WriteFile(path, []byte("---\ntitle: \"Old\"\n---\n\nwritten before encryption\n"), 0o644)
s, err := Open(dir, testBox(t))
if err != nil {
t.Fatal(err)
}
if n, err := s.Get("old-page"); err != nil || n.Title != "Old" {
t.Fatalf("%+v %v", n, err)
}
b, _ := os.ReadFile(path)
if !vault.IsSealed(b) || bytes.Contains(b, []byte("before encryption")) {
t.Fatalf("still in the clear: %q", b)
}
}
func TestWrongKeyRefusesInsteadOfLosingPages(t *testing.T) {
s, dir := open(t)
n := mk(t, s, "Keep", "me")
other, _ := vault.New("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", "https://auth.example.com/", "alice")
if _, err := Open(dir, other); !errors.Is(err, vault.ErrWrongKey) {
t.Fatalf("want ErrWrongKey, got %v", err)
}
// a page file copied over another page's name does not open either
src := filepath.Join(dir, "notes", n.ID+".md")
b, _ := os.ReadFile(src)
os.WriteFile(filepath.Join(dir, "notes", "other-id.md"), b, 0o600)
if _, err := Open(dir, testBox(t)); !errors.Is(err, vault.ErrWrongKey) {
t.Fatalf("a swapped file opened: %v", err)
}
if _, err := Open(dir, nil); err == nil {
t.Fatal("opened with no key")
}
}
+78
View File
@@ -0,0 +1,78 @@
// Package vault encrypts pages at rest. Every person gets a key of their own, made from the server's
// master key and who they are in Authentik, so one person's files cannot be opened (or swapped in)
// with another's key. Who they are is not secret; the master key is what keeps the files private.
package vault
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/hkdf"
"crypto/rand"
"crypto/sha256"
"errors"
"fmt"
)
// ErrWrongKey means a file is encrypted but this key cannot open it: the master key was changed or
// lost, or the file was altered.
var ErrWrongKey = errors.New("these notes were saved with a different encryption key")
// magic starts every encrypted file, so a plain markdown file can still be told apart and read.
var magic = []byte("MAKO1\n")
// MinKey is the shortest master key accepted, in characters.
const MinKey = 32
// Cipher seals and opens the files of one person.
type Cipher struct{ aead cipher.AEAD }
// New makes the cipher for one person from the master key, the Authentik issuer and their subject.
func New(master, issuer, sub string) (*Cipher, error) {
if len(master) < MinKey {
return nil, fmt.Errorf("the encryption key must be at least %d characters", MinKey)
}
if sub == "" {
return nil, errors.New("no user to make an encryption key for")
}
key, err := hkdf.Key(sha256.New, []byte(master), nil, "mako notes v1|"+issuer+"|"+sub, 32)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
return &Cipher{aead}, nil
}
// IsSealed says whether data is an encrypted file.
func IsSealed(data []byte) bool { return bytes.HasPrefix(data, magic) }
// Seal encrypts a file. The name (the page id) is signed into it, so a file copied over another
// page's name no longer opens.
func (c *Cipher) Seal(plain []byte, name string) []byte {
nonce := make([]byte, c.aead.NonceSize())
rand.Read(nonce)
out := make([]byte, 0, len(magic)+len(nonce)+len(plain)+c.aead.Overhead())
out = append(out, magic...)
out = append(out, nonce...)
return c.aead.Seal(out, nonce, plain, []byte(name))
}
// Open decrypts a file made by Seal.
func (c *Cipher) Open(data []byte, name string) ([]byte, error) {
n := len(magic) + c.aead.NonceSize()
if !IsSealed(data) || len(data) < n+c.aead.Overhead() {
return nil, ErrWrongKey
}
plain, err := c.aead.Open(nil, data[len(magic):n], data[n:], []byte(name))
if err != nil {
return nil, ErrWrongKey
}
return plain, nil
}
+71
View File
@@ -0,0 +1,71 @@
package vault
import (
"bytes"
"errors"
"strings"
"testing"
)
const master = "0123456789abcdef0123456789abcdef"
func must(t *testing.T, m, iss, sub string) *Cipher {
t.Helper()
c, err := New(m, iss, sub)
if err != nil {
t.Fatal(err)
}
return c
}
func TestRoundTrip(t *testing.T) {
c := must(t, master, "https://a/", "alice")
plain := []byte("---\ntitle: \"Secret\"\n---\n\ndear diary")
sealed := c.Seal(plain, "page-1")
if !IsSealed(sealed) || bytes.Contains(sealed, []byte("diary")) || bytes.Contains(sealed, []byte("Secret")) {
t.Fatalf("not encrypted: %q", sealed)
}
got, err := c.Open(sealed, "page-1")
if err != nil || !bytes.Equal(got, plain) {
t.Fatalf("open: %q %v", got, err)
}
if bytes.Equal(sealed, c.Seal(plain, "page-1")) {
t.Fatal("two seals of the same text must differ")
}
}
func TestWrongKeyNameOrDamage(t *testing.T) {
c := must(t, master, "https://a/", "alice")
sealed := c.Seal([]byte("hello"), "p1")
cases := map[string]*Cipher{
"another person": must(t, master, "https://a/", "bob"),
"another issuer": must(t, master, "https://b/", "alice"),
"another master key": must(t, strings.Repeat("z", 40), "https://a/", "alice"),
}
for name, other := range cases {
if _, err := other.Open(sealed, "p1"); !errors.Is(err, ErrWrongKey) {
t.Errorf("%s: want ErrWrongKey, got %v", name, err)
}
}
if _, err := c.Open(sealed, "p2"); !errors.Is(err, ErrWrongKey) {
t.Errorf("a file moved to another page name opened: %v", err)
}
bad := append([]byte{}, sealed...)
bad[len(bad)-1] ^= 1
if _, err := c.Open(bad, "p1"); !errors.Is(err, ErrWrongKey) {
t.Errorf("a damaged file opened: %v", err)
}
if _, err := c.Open([]byte("plain text"), "p1"); !errors.Is(err, ErrWrongKey) {
t.Errorf("plain text opened: %v", err)
}
}
func TestNewChecks(t *testing.T) {
if _, err := New("short", "i", "s"); err == nil {
t.Error("a short key was accepted")
}
if _, err := New(master, "i", ""); err == nil {
t.Error("an empty user was accepted")
}
}
+12 -7
View File
@@ -9,6 +9,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io/fs" "io/fs"
"log"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@@ -18,6 +19,7 @@ import (
"git.veillax.com/Veillax/mako/internal/config" "git.veillax.com/Veillax/mako/internal/config"
"git.veillax.com/Veillax/mako/internal/oidc" "git.veillax.com/Veillax/mako/internal/oidc"
"git.veillax.com/Veillax/mako/internal/store" "git.veillax.com/Veillax/mako/internal/store"
"git.veillax.com/Veillax/mako/internal/vault"
) )
//go:embed static //go:embed static
@@ -63,16 +65,19 @@ func (s *Server) notebook(c config.Config, sub, name, email string) (*store.Stor
if st, ok := s.books[key]; ok { if st, ok := s.books[key]; ok {
return st, nil return st, nil
} }
dir := filepath.Join(c.DataDir, "users", key) box, err := vault.New(c.Key, c.OIDC.Issuer, sub)
st, err := store.Open(dir)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// A note for whoever looks after the server, so a folder can be matched to a person. dir := filepath.Join(c.DataDir, "users", key)
who := fmt.Sprintf("%s <%s>\n", name, email) st, err := store.Open(dir, box)
if b, _ := os.ReadFile(filepath.Join(dir, "owner.txt")); string(b) != who { if err != nil {
os.WriteFile(filepath.Join(dir, "owner.txt"), []byte(who), 0o644) log.Printf("could not open the notebook of %s <%s>: %v", name, email, err)
return nil, err
} }
// Earlier versions left a plain note here naming the owner, which does not belong next to
// encrypted pages.
os.Remove(filepath.Join(dir, "owner.txt"))
s.books[key] = st s.books[key] = st
return st, nil return st, nil
} }
@@ -142,7 +147,7 @@ func (s *Server) api(fn handlerFunc) http.HandlerFunc {
} }
st, err := s.notebook(c, ses.Sub, ses.Name, ses.Email) st, err := s.notebook(c, ses.Sub, ses.Name, ses.Email)
if err != nil { if err != nil {
fail(w, http.StatusInternalServerError, "could not open your notes: "+err.Error()) fail(w, http.StatusInternalServerError, "could not open your notes; the server's log says why")
return return
} }
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody) r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
+65
View File
@@ -612,3 +612,68 @@ func TestFailCapitalizes(t *testing.T) {
t.Fatalf("%q", out["error"]) t.Fatalf("%q", out["error"])
} }
} }
func TestNothingIsOnDiskInTheClear(t *testing.T) {
e := setup(t, nil)
a := e.signIn(alice)
e.do("POST", "/api/notes", map[string]string{"title": "Harbour plans", "body": "meet at midnight"}, a)
e.do("POST", "/api/notes", map[string]string{"body": "a quick thought"}, a)
files := 0
filepath.WalkDir(filepath.Join(e.dir, "data"), func(p string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
files++
b, _ := os.ReadFile(p)
for _, s := range []string{"Harbour", "midnight", "quick thought", "Alice", "alice@"} {
if bytes.Contains(b, []byte(s)) {
t.Errorf("%s holds %q in the clear", p, s)
}
}
return nil
})
if files < 2 {
t.Fatalf("only %d files written", files)
}
}
func TestKeyIsMadeOnceAndKept(t *testing.T) {
path := filepath.Join(t.TempDir(), "mako.json")
h, err := config.Load(path)
if err != nil {
t.Fatal(err)
}
if !h.NeedsSave() {
t.Fatal("a new file must be written")
}
h.Save()
key := h.Get().Key
if len(key) < 32 {
t.Fatalf("key %q", key)
}
h2, _ := config.Load(path)
if h2.NeedsSave() || h2.Get().Key != key {
t.Fatal("the key changed or was made again")
}
// a file from before encryption has a secret but no key: one is made and must be written
os.WriteFile(path, []byte(`{"secret":"old-secret"}`), 0o600)
h3, _ := config.Load(path)
if !h3.NeedsSave() || len(h3.Get().Key) < 32 {
t.Fatal("an old file gets no key")
}
// a key from the environment wins and is never written to the file
t.Setenv("MAKO_KEY", strings.Repeat("k", 40))
os.WriteFile(path, []byte(`{"secret":"old-secret"}`), 0o600)
h4, _ := config.Load(path)
h4.Save()
if h4.Get().Key != strings.Repeat("k", 40) {
t.Fatal("the environment key was not used")
}
if b, _ := os.ReadFile(path); strings.Contains(string(b), "kkkk") || strings.Contains(string(b), `"key"`) {
t.Fatalf("the environment key was written to the file: %s", b)
}
t.Setenv("MAKO_KEY", "too short")
if err := h4.Get().Check(); err == nil || !strings.Contains(err.Error(), "encryption key") {
t.Fatalf("a short key was accepted: %v", err)
}
}