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