diff --git a/README.md b/README.md index ce5ef3e..c76073b 100644 --- a/README.md +++ b/README.md @@ -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// - 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 diff --git a/cmd/mako/main.go b/cmd/mako/main.go index 654de99..c5a528c 100644 --- a/cmd/mako/main.go +++ b/cmd/mako/main.go @@ -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) } diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 615d9db..87d5027 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -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 diff --git a/deploy/install.sh b/deploy/install.sh index ea2d687..5d5991b 100644 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -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. diff --git a/internal/config/config.go b/internal/config/config.go index 539f9e8..17b0166 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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' }) { diff --git a/internal/store/store.go b/internal/store/store.go index 590a6e6..ddac9f8 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -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) diff --git a/internal/store/store_test.go b/internal/store/store_test.go index a0cc9e6..4190414 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -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") + } +} diff --git a/internal/vault/vault.go b/internal/vault/vault.go new file mode 100644 index 0000000..5468ba9 --- /dev/null +++ b/internal/vault/vault.go @@ -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 +} diff --git a/internal/vault/vault_test.go b/internal/vault/vault_test.go new file mode 100644 index 0000000..a179c6b --- /dev/null +++ b/internal/vault/vault_test.go @@ -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") + } +} diff --git a/internal/web/server.go b/internal/web/server.go index c869da8..a45688b 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -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) diff --git a/internal/web/web_test.go b/internal/web/web_test.go index a1e5dad..79282dc 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -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) + } +}