Add a passphrase-locked vault
CI / test (push) Successful in 2m2s
Release / release (push) Successful in 2m52s

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:
VeillaxandClaude Sonnet 5.5 committed 2026-11-01 01:40:35 -07:00
1 parent 7ff424fa55
commit c08a8b8718
12 files changed
+706 -28

No files matched your search

+157
View File
@@ -677,3 +677,160 @@ func TestKeyIsMadeOnceAndKept(t *testing.T) {
t.Fatalf("a short key was accepted: %v", err)
}
}
// ---- the vault ----
func withVault(t *testing.T) *env {
t.Helper()
old := vaultIterations
vaultIterations = 1000
t.Cleanup(func() { vaultIterations = old })
return setup(t, nil)
}
// vaultCookies returns the sign-in cookies plus the vault token from a response.
func vaultCookies(ses []*http.Cookie, w *httptest.ResponseRecorder) []*http.Cookie {
if v := cookieNamed(w, vaultCookie); v != nil && v.Value != "" {
return append(append([]*http.Cookie{}, ses...), v)
}
return ses
}
func TestVaultLifecycle(t *testing.T) {
e := withVault(t)
a := e.signIn(alice)
var info struct{ Exists, Unlocked bool }
jsonOf(t, e.do("GET", "/api/vault", nil, a), &info)
if info.Exists || info.Unlocked {
t.Fatalf("a new person already has a vault: %+v", info)
}
if w := e.do("GET", "/api/vault/notes", nil, a); w.Code != http.StatusLocked {
t.Fatalf("a vault that does not exist is not locked: %d", w.Code)
}
if w := e.do("POST", "/api/vault", map[string]string{"passphrase": "short"}, a); w.Code != http.StatusBadRequest {
t.Fatalf("a short passphrase: %d", w.Code)
}
w := e.do("POST", "/api/vault", map[string]string{"passphrase": "correct horse battery"}, a)
if w.Code != http.StatusOK {
t.Fatalf("create: %d %s", w.Code, w.Body)
}
tok := cookieNamed(w, vaultCookie)
if tok == nil || !tok.HttpOnly || tok.Value == "" {
t.Fatalf("vault cookie: %+v", tok)
}
open := vaultCookies(a, w)
// a page in the vault
var n struct{ ID string }
if w := e.do("POST", "/api/vault/notes", map[string]string{"title": "Bank codes", "body": "hunter2 and the safe number"}, open); w.Code != http.StatusOK && w.Code != http.StatusCreated {
t.Fatalf("create page: %d %s", w.Code, w.Body)
} else {
jsonOf(t, w, &n)
}
if w := e.do("GET", "/api/vault/notes/"+n.ID, nil, open); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "hunter2") {
t.Fatalf("read page: %d %s", w.Code, w.Body)
}
// it is nowhere in the ordinary notebook
for _, path := range []string{"/api/notes", "/api/pages", "/api/find?q=hunter2", "/api/find?q=Bank"} {
if w := e.do("GET", path, nil, open); strings.Contains(w.Body.String(), "hunter2") || strings.Contains(w.Body.String(), "Bank codes") {
t.Fatalf("%s shows the vault: %s", path, w.Body)
}
}
if w := e.do("GET", "/api/notes/"+n.ID, nil, open); w.Code != http.StatusNotFound {
t.Fatalf("a vault page opens as an ordinary one: %d", w.Code)
}
if w := e.do("GET", "/api/export", nil, open); bytes.Contains(w.Body.Bytes(), []byte("hunter2")) {
t.Fatal("the vault is in the download")
}
var found struct{ Hits []struct{ ID string } }
jsonOf(t, e.do("GET", "/api/vault/find?q=safe", nil, open), &found)
if len(found.Hits) != 1 {
t.Fatalf("vault search: %+v", found)
}
// locking, and the old cookie no longer opens anything
if w := e.do("POST", "/api/vault/lock", nil, open); w.Code != http.StatusOK {
t.Fatalf("lock: %d", w.Code)
}
if w := e.do("GET", "/api/vault/notes/"+n.ID, nil, open); w.Code != http.StatusLocked {
t.Fatalf("the vault opened after locking: %d", w.Code)
}
// the wrong passphrase, then the right one
if w := e.do("POST", "/api/vault/unlock", map[string]string{"passphrase": "wrong horse battery"}, a); w.Code != http.StatusForbidden {
t.Fatalf("wrong passphrase: %d", w.Code)
}
w = e.do("POST", "/api/vault/unlock", map[string]string{"passphrase": "correct horse battery"}, a)
if w.Code != http.StatusOK {
t.Fatalf("unlock: %d %s", w.Code, w.Body)
}
if w := e.do("GET", "/api/vault/notes/"+n.ID, nil, vaultCookies(a, w)); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "hunter2") {
t.Fatalf("read after unlock: %d", w.Code)
}
if w := e.do("POST", "/api/vault", map[string]string{"passphrase": "another passphrase"}, a); w.Code != http.StatusConflict {
t.Fatalf("a second vault: %d", w.Code)
}
}
func TestVaultIsEncryptedOnDiskAndPrivate(t *testing.T) {
e := withVault(t)
a, b := e.signIn(alice), e.signIn(bob)
w := e.do("POST", "/api/vault", map[string]string{"passphrase": "correct horse battery"}, a)
open := vaultCookies(a, w)
e.do("POST", "/api/vault/notes", map[string]string{"title": "Moonlight plan", "body": "tell nobody"}, open)
filepath.WalkDir(filepath.Join(e.dir, "data"), func(p string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
raw, _ := os.ReadFile(p)
for _, s := range []string{"Moonlight", "tell nobody", "correct horse"} {
if bytes.Contains(raw, []byte(s)) {
t.Errorf("%s holds %q in the clear", p, s)
}
}
return nil
})
// Bob cannot use Alice's vault cookie, and has no vault of his own
stolen := append(append([]*http.Cookie{}, b...), cookieNamed(w, vaultCookie))
if w := e.do("GET", "/api/vault/notes", nil, stolen); w.Code != http.StatusLocked {
t.Fatalf("Bob opened Alice's vault with her cookie: %d", w.Code)
}
if w := e.do("POST", "/api/vault/unlock", map[string]string{"passphrase": "correct horse battery"}, b); w.Code != http.StatusNotFound {
t.Fatalf("Bob unlocking a vault he does not have: %d", w.Code)
}
// signing out locks it
e.do("POST", "/api/logout", nil, open)
if w := e.do("GET", "/api/vault/notes", nil, open); w.Code == http.StatusOK {
t.Fatal("the vault stayed open after signing out")
}
}
func TestVaultGuessingIsLimited(t *testing.T) {
e := withVault(t)
a := e.signIn(alice)
e.do("POST", "/api/vault", map[string]string{"passphrase": "correct horse battery"}, a)
e.do("POST", "/api/vault/lock", nil, a)
for i := 0; i < vaultTries; i++ {
if w := e.do("POST", "/api/vault/unlock", map[string]string{"passphrase": fmt.Sprintf("guess number %d", i)}, a); w.Code != http.StatusForbidden {
t.Fatalf("guess %d: %d", i, w.Code)
}
}
if w := e.do("POST", "/api/vault/unlock", map[string]string{"passphrase": "correct horse battery"}, a); w.Code != http.StatusTooManyRequests {
t.Fatalf("even the right passphrase is held back after too many guesses: %d", w.Code)
}
}
func TestVaultNeedsSignInAndHeader(t *testing.T) {
e := withVault(t)
if w := e.do("GET", "/api/vault", nil, nil); w.Code != http.StatusUnauthorized {
t.Fatalf("no sign-in: %d", w.Code)
}
a := e.signIn(alice)
if w := e.do("POST", "/api/vault", map[string]string{"passphrase": "correct horse battery"}, a, "X-Mako", ""); w.Code != http.StatusForbidden {
t.Fatalf("no header: %d", w.Code)
}
}