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>
This commit is contained in:
1 parent
7ff424fa55
commit
c08a8b8718
12 files changed
+706
-28
No files matched your search
@@ -0,0 +1,291 @@
|
||||
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})
|
||||
}
|
||||
Reference in new issue
Block a user