2 Commits
Author SHA1 Message Date
VeillaxandClaude Sonnet 5.5 2536f223b7 Publish the container image on release
CI / test (push) Successful in 2m0s
Release / release (push) Successful in 3m33s
The compose file pulled git.veillax.com/veillax/mako:latest, which was never built. The release workflow now builds and pushes it, as Hammerhead's does.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 00:35:41 -06:00
VeillaxandClaude Sonnet 5.5 9f2fe4a416 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>
2026-11-01 00:25:37 -06:00
12 changed files with 418 additions and 33 deletions

No files matched your search

+22
View File
@@ -32,3 +32,25 @@ jobs:
env:
TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
run: sh deploy/publish-release.sh "${GITHUB_REF_NAME}"
# Skipped when the repository variable PUBLISH_DOCKER is "false", or fails alone
# if this runner has no Docker: the release above is already published by then.
# Optional secret REGISTRY_TOKEN (and variable REGISTRY_USER): an access token
# with package write permission, for servers where the job token can't push.
- name: Build and push the container image
if: ${{ vars.PUBLISH_DOCKER != 'false' }}
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN || secrets.GITHUB_TOKEN }}
REGISTRY_USER: ${{ vars.REGISTRY_USER || github.actor }}
run: |
host=${GITHUB_SERVER_URL#*://}
owner=$(echo "$GITHUB_REPOSITORY_OWNER" | tr 'A-Z' 'a-z')
image="$host/$owner/mako"
echo "$REGISTRY_TOKEN" | docker login "$host" -u "$REGISTRY_USER" --password-stdin
docker buildx create --name release --driver docker-container --use >/dev/null 2>&1 || docker buildx use release
tags="-t $image:$GITHUB_REF_NAME"
case "$GITHUB_REF_NAME" in
*-*) ;; # pre-releases (v1.0.0-rc1) don't move :latest
*) tags="$tags -t $image:latest" ;;
esac
docker buildx build --platform linux/amd64 $tags --push .
+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.
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.
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_secret` | `MAKO_OIDC_CLIENT_SECRET` | |
| `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` |
| `data_dir` | `MAKO_DATA` | default `data` |
`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.
## 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
```
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.md one file per page
notes/daily-2026-10-31.md the journal page for a day
notes/20261031-143205-a1b2c3 one encrypted file per page (named .md)
notes/daily-2026-10-31 the journal page for a day
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
```
A plain `.md` file dropped into `notes/` is picked up too, with its file time as its date (name it with
lower-case letters, numbers and dashes). Restart Mako to see files added by hand.
A plain `.md` file dropped into `notes/` is picked up when that person's notebook next opens, with its
file time as its date, and is then encrypted (name it with lower-case letters, numbers and dashes).
## 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.
- 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.
- 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
+1 -1
View File
@@ -99,7 +99,7 @@ func serve(args []string) error {
if err != nil {
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 {
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_ALLOWED: you@example.com # optional: limit who may sign in
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.
#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
# Optional. These override what the settings file says.
+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)
}
}