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

+27
View File
@@ -13,6 +13,8 @@ import (
"path/filepath"
"strings"
"sync"
"git.veillax.com/Veillax/mako/internal/vault"
)
// 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 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.
@@ -52,6 +58,11 @@ func (c *Config) Defaults() {
rand.Read(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.OIDC.Issuer = strings.TrimSpace(c.OIDC.Issuer)
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.
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
if c.PublicURL == "" {
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
mu sync.RWMutex
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).
@@ -136,10 +151,21 @@ func Load(path string) (*Holder, error) {
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()
if keyless {
h.file.Key = ""
}
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 }
// 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_CLIENT_ID", &c.OIDC.ClientID)
env("MAKO_OIDC_CLIENT_SECRET", &c.OIDC.ClientSecret)
env("MAKO_KEY", &c.Key)
if v := os.Getenv("MAKO_ALLOWED"); strings.TrimSpace(v) != "" {
c.Allowed = nil
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
// portable with or without Mako. An in-memory index makes listing, searching and linking quick.
// Package store keeps pages as markdown files, one per page, encrypted at rest (see package vault)
// and exportable as plain markdown. An in-memory index makes listing, searching and linking quick.
package store
import (
@@ -20,6 +20,8 @@ import (
"time"
"unicode"
"unicode/utf8"
"git.veillax.com/Veillax/mako/internal/vault"
)
// MaxBody is the longest page, in bytes. MaxTitle is the longest title, in characters.
@@ -60,6 +62,7 @@ type Note struct {
type Store struct {
dir string
box *vault.Cipher
mu sync.RWMutex
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).
func ValidID(s string) bool { return idRe.MatchString(s) }
// Open reads every page under dir, creating the folders if needed.
func Open(dir string) (*Store, error) {
s := &Store{dir: dir, by: map[string]*Note{}}
// Open reads every page under dir, creating the folders if needed. Pages are encrypted with box. A
// plain markdown file found there is still read, and is saved again encrypted.
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"} {
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
}
entries, err := os.ReadDir(filepath.Join(dir, sub))
@@ -97,12 +105,26 @@ func Open(dir string) (*Store, error) {
if err != nil {
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.Trashed = sub == "trash"
s.by[id] = n
if !sealed {
plain = append(plain, n)
}
}
}
s.mend()
for _, n := range plain {
if err := s.write(n); err != nil {
return nil, err
}
}
return s, nil
}
@@ -339,7 +361,7 @@ func isNumber(s string) bool {
func (s *Store) write(n *Note) error {
path := s.path(n)
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 os.Rename(tmp, path)
+82 -8
View File
@@ -10,12 +10,14 @@ import (
"strings"
"testing"
"time"
"git.veillax.com/Veillax/mako/internal/vault"
)
func open(t *testing.T) (*Store, string) {
t.Helper()
dir := t.TempDir()
s, err := Open(dir)
s, err := Open(dir, testBox(t))
if err != nil {
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 {
t.Fatal(err)
}
s2, err := Open(dir)
s2, err := Open(dir, testBox(t))
if err != nil {
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)
mod := time.Date(2025, 3, 4, 5, 6, 7, 0, time.UTC)
os.Chtimes(path, mod, mod)
s2, err := Open(dir)
s2, err := Open(dir, testBox(t))
if err != nil {
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) {
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 {
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", "x2.md"), []byte("---\nparent: x1\n---\ntwo"), 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 {
t.Fatal(err)
}
@@ -299,7 +301,7 @@ func TestTrashRestorePurge(t *testing.T) {
if _, err := s.Create(NewNote{Title: "Child"}); err != nil {
t.Fatal(err)
}
s2, _ := Open(dir)
s2, _ := Open(dir, testBox(t))
back, err := s2.Restore(n.ID)
if err != nil || back.Title != "Child 2" || back.Parent != parent.ID {
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)
}
// 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" {
t.Fatalf("title after reload: %q", n.Title)
}
@@ -465,7 +467,7 @@ func TestIDsCannotEscape(t *testing.T) {
}
_, dir := open(t)
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 {
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"
"fmt"
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
@@ -18,6 +19,7 @@ import (
"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"
)
//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 {
return st, nil
}
dir := filepath.Join(c.DataDir, "users", key)
st, err := store.Open(dir)
box, err := vault.New(c.Key, c.OIDC.Issuer, sub)
if err != nil {
return nil, err
}
// A note for whoever looks after the server, so a folder can be matched to a person.
who := fmt.Sprintf("%s <%s>\n", name, email)
if b, _ := os.ReadFile(filepath.Join(dir, "owner.txt")); string(b) != who {
os.WriteFile(filepath.Join(dir, "owner.txt"), []byte(who), 0o644)
dir := filepath.Join(c.DataDir, "users", key)
st, err := store.Open(dir, box)
if err != nil {
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
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)
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
}
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"])
}
}
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)
}
}