Compare commits
2
Commits
v0.1.0
..
2536f223b7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2536f223b7 | ||
|
|
9f2fe4a416 |
No files matched your search
@@ -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 .
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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