Files
Mako/internal/web/vault.go
T
VeillaxandClaude Sonnet 5.5 994927c704
CI / test (push) Successful in 2m12s
Release / release (push) Successful in 3m42s
Move pages to and from the vault
A page, including a journal day, can be moved into the unlocked vault with its id kept. Links to it elsewhere become [[locked:id]], shown as Locked Page until the vault is unlocked, so its title is kept only in the vault. Move out reverses it.

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

372 lines
11 KiB
Go

package web
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"log"
"net/http"
"os"
"path/filepath"
"time"
"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"
)
// The vault is a second notebook that opens only with a passphrase. Its pages are encrypted with a key
// made from that passphrase together with the server's key, so the server cannot read them until you
// unlock it, and they are in no list, search or download of the ordinary notebook. Unlocked, it lives
// in memory only until you lock it, sign out, or leave it alone for a quarter of an hour.
const (
vaultCookie = "mako_vault"
vaultIdle = 15 * time.Minute
vaultMinPass = 10
vaultTries = 5
vaultWindow = 10 * time.Minute
)
// vaultIterations is how slow it is to check one passphrase guess. Tests lower it.
var vaultIterations = 600_000
type vaultFile struct {
Salt string `json:"salt"`
Iter int `json:"iter"`
Check string `json:"check"` // a sealed word that only the right passphrase can open
}
// vaultState is an unlocked vault: one store shared by every browser holding a token for it.
type vaultState struct {
st *store.Store
tokens map[string]time.Time // cookie value to when it lapses
}
func vaultDir(c config.Config, sub string) string {
return filepath.Join(c.DataDir, "users", userKey(c, sub), "vault")
}
func readVaultFile(dir string) (vaultFile, bool) {
var vf vaultFile
b, err := os.ReadFile(filepath.Join(dir, "vault.json"))
if err != nil || json.Unmarshal(b, &vf) != nil || vf.Salt == "" || vf.Iter < 1 {
return vaultFile{}, false
}
return vf, true
}
func vaultBox(c config.Config, sub, pass string, vf vaultFile) (*vault.Cipher, error) {
salt, err := base64.RawURLEncoding.DecodeString(vf.Salt)
if err != nil {
return nil, err
}
dk, err := vault.Passphrase(pass, salt, vf.Iter)
if err != nil {
return nil, err
}
return vault.New(c.Key+"|"+dk, c.OIDC.Issuer+"|vault", sub)
}
// sweepVaults forgets tokens that have lapsed, and vaults nobody holds a token for.
func (s *Server) sweepVaults(now time.Time) {
for uk, vs := range s.vstate {
for tok, exp := range vs.tokens {
if now.After(exp) {
delete(vs.tokens, tok)
delete(s.vtoken, tok)
}
}
if len(vs.tokens) == 0 {
delete(s.vstate, uk)
}
}
}
// openVault returns the unlocked vault this browser holds the token for, and keeps it open a while longer.
func (s *Server) openVault(c config.Config, r *http.Request, sub string) (string, *store.Store, bool) {
ck, err := r.Cookie(vaultCookie)
if err != nil {
return "", nil, false
}
now := time.Now()
s.vmu.Lock()
defer s.vmu.Unlock()
s.sweepVaults(now)
uk := s.vtoken[ck.Value]
vs := s.vstate[uk]
if vs == nil || uk != userKey(c, sub) {
return "", nil, false
}
vs.tokens[ck.Value] = now.Add(vaultIdle)
return ck.Value, vs.st, true
}
// startVault hands this browser a token for the vault, opening it if no one else has.
func (s *Server) startVault(w http.ResponseWriter, c config.Config, sub string, box *vault.Cipher) error {
uk := userKey(c, sub)
s.vmu.Lock()
defer s.vmu.Unlock()
s.sweepVaults(time.Now())
vs := s.vstate[uk]
if vs == nil {
st, err := store.Open(vaultDir(c, sub), box)
if err != nil {
return err
}
vs = &vaultState{st: st, tokens: map[string]time.Time{}}
s.vstate[uk] = vs
}
tok := oidc.Random(32)
vs.tokens[tok] = time.Now().Add(vaultIdle)
s.vtoken[tok] = uk
setCookie(w, c, vaultCookie, tok, "/", vaultIdle)
return nil
}
// lockVault drops this browser's token. The vault leaves memory once nobody holds one.
func (s *Server) lockVault(w http.ResponseWriter, r *http.Request) {
c := s.cfg.Get()
if ck, err := r.Cookie(vaultCookie); err == nil {
s.vmu.Lock()
if uk, ok := s.vtoken[ck.Value]; ok {
delete(s.vtoken, ck.Value)
if vs := s.vstate[uk]; vs != nil {
delete(vs.tokens, ck.Value)
if len(vs.tokens) == 0 {
delete(s.vstate, uk)
}
}
}
s.vmu.Unlock()
}
setCookie(w, c, vaultCookie, "", "/", 0)
}
// vaultAPI is api for the pages in the vault: it needs the vault to be unlocked as well as a sign-in.
func (s *Server) vaultAPI(fn handlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
c := s.cfg.Get()
ses, ok := s.session(c, r)
if !ok {
fail(w, http.StatusUnauthorized, "sign in required")
return
}
if r.Method != http.MethodGet && r.Header.Get("X-Mako") != "1" {
fail(w, http.StatusForbidden, "missing X-Mako header")
return
}
tok, st, ok := s.openVault(c, r, ses.Sub)
if !ok {
failCode(w, http.StatusLocked, "the vault is locked", "locked")
return
}
setCookie(w, c, vaultCookie, tok, "/", vaultIdle)
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
fn(w, r, principal{Sub: ses.Sub, Name: ses.Name, Email: ses.Email, st: st})
}
}
func (s *Server) vaultInfo(w http.ResponseWriter, r *http.Request, p principal) {
c := s.cfg.Get()
_, exists := readVaultFile(vaultDir(c, p.Sub))
_, _, unlocked := s.openVault(c, r, p.Sub)
writeJSON(w, map[string]bool{"exists": exists, "unlocked": unlocked})
}
type passphraseBody struct {
Passphrase string `json:"passphrase"`
}
func (s *Server) vaultCreate(w http.ResponseWriter, r *http.Request, p principal) {
var in passphraseBody
if !decode(w, r, &in) {
return
}
c := s.cfg.Get()
dir := vaultDir(c, p.Sub)
if _, exists := readVaultFile(dir); exists {
fail(w, http.StatusConflict, "you already have a vault; unlock it instead")
return
}
if len([]rune(in.Passphrase)) < vaultMinPass {
fail(w, http.StatusBadRequest, "pick a passphrase of at least 10 characters; a few words strung together is good")
return
}
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
fail(w, http.StatusInternalServerError, "could not make a vault")
return
}
vf := vaultFile{Salt: base64.RawURLEncoding.EncodeToString(salt), Iter: vaultIterations}
box, err := vaultBox(c, p.Sub, in.Passphrase, vf)
if err != nil {
fail(w, http.StatusInternalServerError, "could not make a vault")
return
}
vf.Check = base64.RawURLEncoding.EncodeToString(box.Seal([]byte("mako vault"), "check"))
b, _ := json.Marshal(vf)
if err := os.MkdirAll(dir, 0o700); err != nil {
log.Printf("vault: %v", err)
fail(w, http.StatusInternalServerError, "could not make a vault")
return
}
tmp := filepath.Join(dir, ".vault.json.tmp")
err = os.WriteFile(tmp, b, 0o600)
if err == nil {
err = os.Rename(tmp, filepath.Join(dir, "vault.json"))
}
if err != nil {
log.Printf("vault: %v", err)
fail(w, http.StatusInternalServerError, "could not make a vault")
return
}
if err := s.startVault(w, c, p.Sub, box); err != nil {
log.Printf("vault: %v", err)
fail(w, http.StatusInternalServerError, "could not open the vault; the server's log says why")
return
}
writeJSON(w, map[string]bool{"exists": true, "unlocked": true})
}
func (s *Server) vaultUnlock(w http.ResponseWriter, r *http.Request, p principal) {
var in passphraseBody
if !decode(w, r, &in) {
return
}
c := s.cfg.Get()
vf, exists := readVaultFile(vaultDir(c, p.Sub))
if !exists {
fail(w, http.StatusNotFound, "you have no vault yet")
return
}
uk, now := userKey(c, p.Sub), time.Now()
s.vmu.Lock()
recent := s.vfails[uk][:0]
for _, t := range s.vfails[uk] {
if now.Sub(t) < vaultWindow {
recent = append(recent, t)
}
}
s.vfails[uk] = recent
tooMany := len(recent) >= vaultTries
s.vmu.Unlock()
if tooMany {
failCode(w, http.StatusTooManyRequests, "too many wrong passphrases; try again in a few minutes", "too_many")
return
}
box, err := vaultBox(c, p.Sub, in.Passphrase, vf)
check, _ := base64.RawURLEncoding.DecodeString(vf.Check)
var word []byte
if err == nil {
word, err = box.Open(check, "check")
}
if err != nil || subtle.ConstantTimeCompare(word, []byte("mako vault")) != 1 {
s.vmu.Lock()
s.vfails[uk] = append(s.vfails[uk], now)
s.vmu.Unlock()
failCode(w, http.StatusForbidden, "that is not the passphrase", "wrong_passphrase")
return
}
s.vmu.Lock()
delete(s.vfails, uk)
s.vmu.Unlock()
if err := s.startVault(w, c, p.Sub, box); err != nil {
log.Printf("vault: %v", err)
if errors.Is(err, vault.ErrWrongKey) {
fail(w, http.StatusInternalServerError, "the vault's pages cannot be read with this key; the server's log says more")
} else {
fail(w, http.StatusInternalServerError, "could not open the vault; the server's log says why")
}
return
}
writeJSON(w, map[string]bool{"exists": true, "unlocked": true})
}
func (s *Server) vaultLock(w http.ResponseWriter, r *http.Request, p principal) {
s.lockVault(w, r)
writeJSON(w, map[string]bool{"exists": true, "unlocked": false})
}
// moveToVault takes a page out of the ordinary notebook and puts it in the vault, which must be unlocked.
// Links to it elsewhere become [[locked:id]], so the page's title stays in the vault only.
func (s *Server) moveToVault(w http.ResponseWriter, r *http.Request, p principal) {
c := s.cfg.Get()
_, vst, ok := s.openVault(c, r, p.Sub)
if !ok {
failCode(w, http.StatusLocked, "unlock the vault first", "locked")
return
}
id := r.PathValue("id")
n, err := p.st.Get(id)
if err == nil && n.Trashed {
err = store.ErrNotFound
}
if err != nil {
storeFail(w, err)
return
}
if _, err := vst.Import(n); err != nil {
if errors.Is(err, store.ErrExists) {
failCode(w, http.StatusConflict, existsMessage(n, "your vault"), "exists")
return
}
storeFail(w, err)
return
}
if _, err := p.st.Detach(id); err != nil {
vst.Discard(id) // the page stays where it was
storeFail(w, err)
return
}
writeJSON(w, map[string]string{"id": id})
}
// moveFromVault brings a page back out of the vault into the ordinary notebook and points the
// [[locked:id]] links to it at its title again.
func (s *Server) moveFromVault(w http.ResponseWriter, r *http.Request, p principal) {
main, err := s.notebook(s.cfg.Get(), p.Sub, p.Name, p.Email)
if err != nil {
fail(w, http.StatusInternalServerError, "could not open your notes; the server's log says why")
return
}
id := r.PathValue("id")
n, err := p.st.Get(id)
if err == nil && n.Trashed {
err = store.ErrNotFound
}
if err != nil {
storeFail(w, err)
return
}
in, err := main.Import(n)
if err != nil {
if errors.Is(err, store.ErrExists) {
failCode(w, http.StatusConflict, existsMessage(n, "your notebook"), "exists")
return
}
storeFail(w, err)
return
}
if err := main.Reveal(id, in.Title); err != nil {
main.Discard(id)
storeFail(w, err)
return
}
if err := p.st.Discard(id); err != nil {
log.Printf("vault: moved %s out but could not remove it from the vault: %v", id, err)
fail(w, http.StatusInternalServerError, "the page is back in your notebook, but a copy is still in the vault; the server's log says why")
return
}
writeJSON(w, map[string]string{"id": id})
}
func existsMessage(n store.Note, where string) string {
if n.Daily != "" {
return where + " already has a journal page for that day"
}
return where + " already has this page"
}