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

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