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
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user