Encrypt pages at rest with a key per person
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:
1 parent
e3147597e8
commit
9f2fe4a416
11 files changed
+396
-33
No files matched your search
@@ -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
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user