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
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user