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" }