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>
372 lines
11 KiB
Go
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"
|
|
}
|