Files
Mako/internal/vault/vault.go
T
VeillaxandClaude Sonnet 5.5 c08a8b8718
CI / test (push) Successful in 2m2s
Release / release (push) Successful in 2m52s
Add a passphrase-locked vault
A second notebook whose pages are encrypted with a key made from a passphrase plus the server key. It is unlocked into memory only (15 minutes idle, Lock, or sign-out), is not in search, lists or downloads of the ordinary notebook, and limits wrong guesses.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 01:40:35 -07:00

88 lines
2.8 KiB
Go

// 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/pbkdf2"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"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
}
// Passphrase stretches a passphrase into key material (hex) with PBKDF2-HMAC-SHA256, which is slow on
// purpose so that guessing passphrases is expensive.
func Passphrase(pass string, salt []byte, iterations int) (string, error) {
k, err := pbkdf2.Key(sha256.New, pass, salt, iterations, 32)
return hex.EncodeToString(k), err
}
// 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
}