Let an admin delete a vault, behind their own password
ci/woodpecker/push/build Pipeline was successful

The web interface could create a vault and never remove one, so the only
way to undo a mistake was the CLI. There is now a Delete link on every
vault you administer, leading to a page that says what is about to go —
owner, versions stored, grants pointing at it — and asks for your
account password before it goes.

The gate is re-authentication, not a confirmation checkbox. A session
token is enough to read and write a vault; it should not be enough to
erase every version of one, because a session is what an unlocked
laptop leaves lying around. The one-time code is required too when the
account has one enrolled.

It lives in confirmDeletion, so the rule holds wherever deletion is
reachable: DELETE /api/v1/databases/{id} now takes {password, totp?}
and answers 401 without it, and keeporg db -delete prompts. That is a
breaking change to the endpoint, which is why the server goes to 0.7.0
and the desktop clients to 0.4.0.

An account that signs in only through an identity provider has no
password to type. Rather than lock those deployments out of deleting
anything, they confirm by typing the vault's exact name — the weaker
gate, and deliberately the fallback.

Deleting takes the record, its grants, every stored version and the key
a merge vault left with the server, and tells the watchers over SSE.
Copies already in people's mirror directories stay where they are: the
server stops tracking a vault, it does not reach into anybody's home
directory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Reactorcoremeltdown
2026-10-30 12:57:02 +01:00
co-authored by Claude Opus 5
parent 967d9c0e2e
commit e0977a9241
12 changed files with 521 additions and 21 deletions
+15 -2
View File
@@ -412,7 +412,8 @@ func cmdDB(ctx context.Context, args []string) error {
"merge strategy: merge, server-wins, client-wins or manual") "merge strategy: merge, server-wins, client-wins or manual")
id := fs.String("id", "", "database id, for -strategy, -rename or -delete") id := fs.String("id", "", "database id, for -strategy, -rename or -delete")
rename := fs.String("rename", "", "new name for -id") rename := fs.String("rename", "", "new name for -id")
del := fs.Bool("delete", false, "delete the database given by -id") del := fs.Bool("delete", false,
"delete the database given by -id, every stored version of it included")
storePassword := fs.Bool("store-password", false, storePassword := fs.Bool("store-password", false,
"give the server this vault's password so it can merge the vault") "give the server this vault's password so it can merge the vault")
forgetPassword := fs.Bool("forget-password", false, forgetPassword := fs.Bool("forget-password", false,
@@ -463,7 +464,19 @@ func cmdDB(ctx context.Context, args []string) error {
} }
fmt.Printf("%s now uses %s\n", db.Name, db.MergeStrategy) fmt.Printf("%s now uses %s\n", db.Name, db.MergeStrategy)
case *id != "" && *del: case *id != "" && *del:
if err := api.DeleteDatabase(ctx, *id); err != nil { password, perr := readPassword("Your account password: ")
if perr != nil {
return perr
}
err := api.DeleteDatabase(ctx, *id, password, "")
if client.Code(err) == "totp_required" || client.Code(err) == "totp_invalid" {
code, cerr := prompt("One-time code: ")
if cerr != nil {
return cerr
}
err = api.DeleteDatabase(ctx, *id, password, code)
}
if err != nil {
return err return err
} }
fmt.Println("deleted on the server; local files were left in place") fmt.Println("deleted on the server; local files were left in place")
+20
View File
@@ -353,6 +353,7 @@ browsing, by design.
| Page | What it does | | Page | What it does |
| --- | --- | | --- | --- |
| `/web/vaults` | the vaults you can reach, the create-a-vault form, your own account password | | `/web/vaults` | the vaults you can reach, the create-a-vault form, your own account password |
| `/web/vaults/{id}/delete` | what deleting a vault destroys, and the password that confirms it |
| `/web/groups` | the group tree you can see, and a form to add a group inside one you administer | | `/web/groups` | the group tree you can see, and a form to add a group inside one you administer |
| `/web/groups/{id}` | one group: members and their roles, the vaults it reaches and where that access comes from, grants given and revoked, deletion | | `/web/groups/{id}` | one group: members and their roles, the vaults it reaches and where that access comes from, grants given and revoked, deletion |
| `/web/users` | the accounts you may see; for a server administrator, disable, enable, clear the second factor, promote, demote, delete | | `/web/users` | the accounts you may see; for a server administrator, disable, enable, clear the second factor, promote, demote, delete |
@@ -393,6 +394,25 @@ No page carries any JavaScript. Each control is its own small form with the
CSRF token and one action in it, which is what makes the interface work in a CSRF token and one action in it, which is what makes the interface work in a
locked-down browser and keeps its attack surface down to that token. locked-down browser and keeps its attack surface down to that token.
**Deleting a vault asks for the password again.** Every other control on
these pages is undoable or additive; this one erases every stored version of
a file. A live session is what an unlocked laptop leaves lying around, so the
confirmation page states what is about to go — owner, versions, grants — and
takes the administrator's own account password, plus their one-time code when
they have one. The same rule covers the API and `keeporg db -delete`, because
it lives in `confirmDeletion` rather than in the page.
An account that signs in only through an identity provider has no password to
type. Refusing those accounts would mean a deployment where nobody can delete
anything, so they confirm by typing the vault's exact name. That is a weaker
gate, and it is deliberately the fallback rather than the rule.
Deleting takes the record, the grants pointing at it, every stored version
and — for a merge vault — the key the server held. It does not touch the
copies in people's mirror directories: the server stops tracking the vault, it
does not reach into anybody's home directory. Clients are told over SSE, so
they stop expecting the file rather than discovering it at the next save.
Creating a vault is one transaction: the server builds an empty KDBX 4 Creating a vault is one transaction: the server builds an empty KDBX 4
database, encrypts it with the password from the form, stores it as version database, encrypts it with the password from the form, stores it as version
1, and — for a merge vault — wraps the password under the master key. If the 1, and — for a merge vault — wraps the password under the master key. If the
+9 -1
View File
@@ -116,7 +116,7 @@ the group named or any group above it.
| DELETE | `/api/v1/groups/{id}/members/{user}` | – | group admin | | DELETE | `/api/v1/groups/{id}/members/{user}` | – | group admin |
| POST | `/api/v1/databases` | `{name, owner_group_id, merge_strategy}` | group admin of the owner | | POST | `/api/v1/databases` | `{name, owner_group_id, merge_strategy}` | group admin of the owner |
| PATCH | `/api/v1/databases/{id}` | `{name?, merge_strategy?}` | database admin | | PATCH | `/api/v1/databases/{id}` | `{name?, merge_strategy?}` | database admin |
| DELETE | `/api/v1/databases/{id}` | – | database admin; deletes every stored version | | DELETE | `/api/v1/databases/{id}` | `{password, totp?}` | database admin, re-proving their own password; deletes every stored version |
| POST | `/api/v1/grants` | `{group_id, database_id, perm}` | database admin | | POST | `/api/v1/grants` | `{group_id, database_id, perm}` | database admin |
| DELETE | `/api/v1/grants` | `?group_id=&database_id=` | database admin | | DELETE | `/api/v1/grants` | `?group_id=&database_id=` | database admin |
| POST | `/api/v1/invites` | `{group_id, role, ttl_hours, max_uses}` | group admin | | POST | `/api/v1/invites` | `{group_id, role, ttl_hours, max_uses}` | group admin |
@@ -131,6 +131,14 @@ Switching to `merge` requires a stored key and is otherwise refused with
`400`. `perm` is one of `read`, `write`, `admin`. `role` is `member` or `400`. `perm` is one of `read`, `write`, `admin`. `role` is `member` or
`admin`. `admin`.
Deleting a database is the one call that asks for credentials again. A
session token is enough to read and write a vault and not enough to destroy
every version of it, so the body carries the caller's own account password,
and `totp` as well when that account has a second factor enrolled — the
server answers `401 totp_required` when it is missing. An account that signs
in only through an identity provider has no password to send and passes
`{"confirm_name": "<the database's exact name>"}` instead.
An account `action` is one of `disable`, `enable`, `promote`, `demote`, An account `action` is one of `disable`, `enable`, `promote`, `demote`,
`clear-totp` or `delete`. `disable` and `delete` also revoke every session `clear-totp` or `delete`. `disable` and `delete` also revoke every session
that account holds. The three that would lock the caller out — `disable`, that account holds. The three that would lock the caller out — `disable`,
+7 -2
View File
@@ -103,8 +103,13 @@ func (a *API) RenameDatabase(ctx context.Context, dbID, name string) (*Database,
map[string]string{"name": name}, &out) map[string]string{"name": name}, &out)
} }
func (a *API) DeleteDatabase(ctx context.Context, dbID string) error { // DeleteDatabase removes a database and every version of it stored on the
return a.do(ctx, http.MethodDelete, "/api/v1/databases/"+url.PathEscape(dbID), nil, nil) // server. The caller's own account password is required, and the one-time
// code too when that account has one enrolled; the server answers
// totp_required when it is missing.
func (a *API) DeleteDatabase(ctx context.Context, dbID, password, totp string) error {
return a.do(ctx, http.MethodDelete, "/api/v1/databases/"+url.PathEscape(dbID),
map[string]string{"password": password, "totp": totp}, nil)
} }
func (a *API) SetGrant(ctx context.Context, groupID, dbID, perm string) error { func (a *API) SetGrant(ctx context.Context, groupID, dbID, perm string) error {
+82 -8
View File
@@ -19,6 +19,23 @@ var errForbidden = errors.New("forbidden")
// may do what, and the only durable way to make them agree is for both to // may do what, and the only durable way to make them agree is for both to
// call the same function. // call the same function.
// denyAuth answers a request that failed to re-prove its credentials. The
// cases stay distinct so a client knows to prompt for the factor it is
// missing rather than reporting a flat refusal.
func denyAuth(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, errTOTPRequired):
fail(w, http.StatusUnauthorized, "totp_required", "second factor required")
case errors.Is(err, errTOTPInvalid):
fail(w, http.StatusUnauthorized, "totp_invalid", "second factor rejected")
case errors.Is(err, errBadCredentials):
fail(w, http.StatusUnauthorized, "bad_credentials",
"your own account password is required for this")
default:
deny(w, err)
}
}
// deny maps a domain error onto a response. // deny maps a domain error onto a response.
func deny(w http.ResponseWriter, err error) { func deny(w http.ResponseWriter, err error) {
switch { switch {
@@ -457,29 +474,86 @@ func (s *Server) handlePatchDatabase(w http.ResponseWriter, r *http.Request, u *
writeJSON(w, http.StatusOK, out) writeJSON(w, http.StatusOK, out)
} }
func (s *Server) handleDeleteDatabase(w http.ResponseWriter, r *http.Request, u *store.User) { // errConfirmName is the answer when an account with no password of its own
id := r.PathValue("id") // did not type the name of the thing it is about to destroy.
var watchers []string var errConfirmName = errors.New("type the name exactly to confirm")
// confirmDeletion re-proves who the caller is before something irreversible.
// Holding a session is enough to read and write a vault; it is not enough to
// destroy every version of one, because a session is what an unlocked laptop
// leaves lying around.
//
// An account that signs in only through an identity provider has no password
// to type. Refusing those accounts would mean a deployment where nobody can
// delete anything, so they confirm by typing the exact name instead. That is
// a weaker gate and it is deliberately the fallback, not the rule.
func confirmDeletion(st *store.State, u *store.User, name, password, totpCode, typed string) error {
target := st.Users[u.ID]
if target == nil {
return errBadCredentials
}
if target.PassHash == "" {
if strings.TrimSpace(typed) != name {
return errConfirmName
}
return nil
}
_, err := checkPassword(st, target.Username, password, totpCode)
return err
}
// deleteDatabase removes a database, its grants and every stored version of
// the file. Clients keep whatever they already have on disk: the server can
// stop tracking a vault, but it has no business reaching into somebody's
// home directory.
func (s *Server) deleteDatabase(u *store.User, id, password, totpCode, typed string) (string, error) {
var (
name string
watchers []string
)
err := s.st.Update(func(st *store.State) error { err := s.st.Update(func(st *store.State) error {
if st.Databases[id] == nil { db := st.Databases[id]
if db == nil {
return errors.New("no such database") return errors.New("no such database")
} }
if st.DatabasePerm(u.ID, id) != store.PermAdmin { if st.DatabasePerm(u.ID, id) != store.PermAdmin {
return errForbidden return errForbidden
} }
if cerr := confirmDeletion(st, u, db.Name, password, totpCode, typed); cerr != nil {
return cerr
}
name = db.Name
// Taken before the delete: the people to tell are the ones who are
// about to stop having access.
watchers = st.Watchers(id) watchers = st.Watchers(id)
delete(st.Databases, id) delete(st.Databases, id)
st.Grants = filterGrants(st.Grants, func(g store.Grant) bool { return g.DatabaseID != id }) st.Grants = filterGrants(st.Grants, func(g store.Grant) bool { return g.DatabaseID != id })
return nil return nil
}) })
if err != nil { if err != nil {
deny(w, err) return "", err
return
} }
if err := s.blobs.Forget(id); err != nil { if ferr := s.blobs.Forget(id); ferr != nil {
s.log.Printf("delete blobs for %s: %v", id, err) s.log.Printf("delete blobs for %s: %v", id, ferr)
} }
s.hub.Publish(watchers, Event{Type: EventChanged, DatabaseID: id}) s.hub.Publish(watchers, Event{Type: EventChanged, DatabaseID: id})
s.log.Printf("%s deleted database %q (%s) and every stored version", u.Username, name, id)
return name, nil
}
func (s *Server) handleDeleteDatabase(w http.ResponseWriter, r *http.Request, u *store.User) {
var req struct {
Password string `json:"password"`
TOTP string `json:"totp"`
Confirm string `json:"confirm_name"`
}
if r.ContentLength > 0 && !readJSON(w, r, &req) {
return
}
if _, err := s.deleteDatabase(u, r.PathValue("id"), req.Password, req.TOTP, req.Confirm); err != nil {
denyAuth(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
} }
+237
View File
@@ -0,0 +1,237 @@
package server
import (
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"cc.tiredsysadmin.keeporg/internal/secret"
"cc.tiredsysadmin.keeporg/internal/store"
"cc.tiredsysadmin.keeporg/internal/totp"
)
// blobsExist reports whether the server still keeps any version of a
// database on disk.
func (h *harness) blobsExist(dbID string) bool {
h.t.Helper()
_, err := os.Stat(filepath.Join(h.dataDir, "blobs", dbID))
return err == nil
}
func TestWebDeletesAVaultOnlyWithTheAdminsPassword(t *testing.T) {
h := newHarness(t)
b := newBrowser(t, h)
csrf := b.signIn()
admin, _, _ := h.login("admin", adminPassword, "")
id := h.makeVault(admin, "Doomed")
if !h.blobsExist(id) {
t.Fatal("the new vault has no stored version")
}
// A grant so the delete has something to clean up besides the record.
if status, body := h.call("POST", "/api/v1/grants", admin, map[string]any{
"group_id": "everyone", "database_id": id, "perm": "read",
}); status != http.StatusOK {
t.Fatalf("grant: %d %v", status, body)
}
status, page := b.get("/web/vaults/" + id + "/delete")
if status != http.StatusOK || !strings.Contains(page, "Delete Doomed.kdbx?") {
t.Fatalf("the confirmation page = %d\n%s", status, page)
}
if !strings.Contains(page, "Your account password") {
t.Fatalf("the page does not ask for a password:\n%s", page)
}
_, page = b.post("/web/vaults/"+id+"/delete", url.Values{
"csrf": {csrf}, "password": {"not-the-password"}})
if !strings.Contains(page, "not your account password") {
t.Fatalf("a wrong password was not reported:\n%s", page)
}
h.srv.Store().Read(func(st *store.State) {
if st.Databases[id] == nil {
t.Fatal("the vault was deleted on a wrong password")
}
})
if !h.blobsExist(id) {
t.Fatal("the stored versions went on a wrong password")
}
_, page = b.post("/web/vaults/"+id+"/delete", url.Values{
"csrf": {csrf}, "password": {adminPassword}})
if !strings.Contains(page, "Deleted Doomed") {
t.Fatalf("delete:\n%s", page)
}
if strings.Contains(page, "Doomed.kdbx") {
t.Fatal("the deleted vault is still listed")
}
h.srv.Store().Read(func(st *store.State) {
if st.Databases[id] != nil {
t.Fatal("the record survived")
}
for _, g := range st.Grants {
if g.DatabaseID == id {
t.Fatal("a grant outlived the vault")
}
}
})
if h.blobsExist(id) {
t.Fatal("the stored versions outlived the vault")
}
}
// Write access is not administration: it must not reach the page, and must
// not delete even with the right password.
func TestWebDeleteIsRefusedWithoutAdminRights(t *testing.T) {
h := newHarness(t)
b := newBrowser(t, h)
csrf := b.signIn()
admin, _, _ := h.login("admin", adminPassword, "")
id := h.makeVault(admin, "Theirs")
h.join(b.invite(csrf, "everyone", "member"), "wally", "wally-password-1")
if status, body := h.call("POST", "/api/v1/grants", admin, map[string]any{
"group_id": "everyone", "database_id": id, "perm": "write",
}); status != http.StatusOK {
t.Fatalf("grant: %d %v", status, body)
}
w := newBrowser(t, h)
wCSRF := w.signInAs("wally", "wally-password-1")
_, page := w.get("/web/vaults/" + id + "/delete")
if !strings.Contains(page, "you do not administer it") {
t.Fatalf("a writer reached the delete page:\n%s", page)
}
_, page = w.post("/web/vaults/"+id+"/delete", url.Values{
"csrf": {wCSRF}, "password": {"wally-password-1"}})
if !strings.Contains(page, "you do not administer it") {
t.Fatalf("a writer's delete was not refused:\n%s", page)
}
h.srv.Store().Read(func(st *store.State) {
if st.Databases[id] == nil {
t.Fatal("a writer deleted the vault")
}
})
// The vault list offers them no way in either.
_, page = w.get("/web/vaults")
if strings.Contains(page, "/delete") {
t.Fatalf("a writer was offered a delete link:\n%s", page)
}
}
func TestAPIDeleteNeedsThePassword(t *testing.T) {
h := newHarness(t)
admin, _, _ := h.login("admin", adminPassword, "")
id := h.makeVault(admin, "By API")
status, body := h.call("DELETE", "/api/v1/databases/"+id, admin, nil)
if status != http.StatusUnauthorized || body["code"] != "bad_credentials" {
t.Fatalf("delete with no password: %d %v", status, body)
}
status, body = h.call("DELETE", "/api/v1/databases/"+id, admin,
map[string]string{"password": "wrong"})
if status != http.StatusUnauthorized {
t.Fatalf("delete with a wrong password: %d %v", status, body)
}
h.srv.Store().Read(func(st *store.State) {
if st.Databases[id] == nil {
t.Fatal("the vault went without a password")
}
})
if status, body = h.call("DELETE", "/api/v1/databases/"+id, admin,
map[string]string{"password": adminPassword}); status != http.StatusOK {
t.Fatalf("delete with the password: %d %v", status, body)
}
if h.blobsExist(id) {
t.Fatal("the stored versions outlived the vault")
}
}
func TestDeleteAsksForTheSecondFactorToo(t *testing.T) {
h := newHarness(t)
admin, _, _ := h.login("admin", adminPassword, "")
id := h.makeVault(admin, "Guarded")
status, body := h.call("POST", "/api/v1/auth/totp/enroll", admin, nil)
if status != http.StatusOK {
t.Fatalf("enroll: %d %v", status, body)
}
sec := body["secret"].(string)
code, err := totp.Code(sec, time.Now())
if err != nil {
t.Fatal(err)
}
if status, body = h.call("POST", "/api/v1/auth/totp/confirm", admin,
map[string]string{"code": code}); status != http.StatusOK {
t.Fatalf("confirm: %d %v", status, body)
}
if status, body = h.call("DELETE", "/api/v1/databases/"+id, admin,
map[string]string{"password": adminPassword}); status != http.StatusUnauthorized ||
body["code"] != "totp_required" {
t.Fatalf("delete without a code: %d %v", status, body)
}
code, _ = totp.Code(sec, time.Now())
if status, body = h.call("DELETE", "/api/v1/databases/"+id, admin,
map[string]string{"password": adminPassword, "totp": code}); status != http.StatusOK {
t.Fatalf("delete with the code: %d %v", status, body)
}
}
// An account that only ever signs in through an identity provider has no
// password to re-type, so it confirms with the vault's name instead.
func TestSingleSignOnAccountConfirmsByName(t *testing.T) {
h := newHarness(t)
admin, _, _ := h.login("admin", adminPassword, "")
id := h.makeVault(admin, "Federated")
sso := &store.User{ID: secret.ID(), Username: "sso", OIDCIssuer: "https://id.example.com",
OIDCSubject: "abc", ServerAdmin: true, CreatedAt: time.Now()}
if err := h.srv.Store().Update(func(st *store.State) error {
st.Users[sso.ID] = sso
return nil
}); err != nil {
t.Fatal(err)
}
if _, err := h.srv.deleteDatabase(sso, id, "", "", ""); err != errConfirmName {
t.Fatalf("delete with nothing typed = %v", err)
}
if _, err := h.srv.deleteDatabase(sso, id, "", "", "federated"); err != errConfirmName {
t.Fatalf("delete with the wrong case = %v", err)
}
if _, err := h.srv.deleteDatabase(sso, id, "", "", "Federated"); err != nil {
t.Fatalf("delete with the name typed = %v", err)
}
h.srv.Store().Read(func(st *store.State) {
if st.Databases[id] != nil {
t.Fatal("the vault survived a confirmed delete")
}
})
}
// Deleting a vault tells everyone who could reach it, so their client stops
// expecting the file rather than finding out at the next save.
func TestDeleteNotifiesTheWatchers(t *testing.T) {
h := newHarness(t)
admin, _, _ := h.login("admin", adminPassword, "")
id := h.makeVault(admin, "Watched")
sub, events := h.srv.hub.Subscribe("admin")
defer h.srv.hub.Unsubscribe(sub)
if _, err := h.srv.deleteDatabase(&store.User{ID: "admin", Username: "admin"},
id, adminPassword, "", ""); err != nil {
t.Fatal(err)
}
select {
case ev := <-events:
if ev.DatabaseID != id || ev.Type != EventChanged {
t.Fatalf("event = %+v", ev)
}
case <-time.After(2 * time.Second):
t.Fatal("no event reached a watcher")
}
}
+62 -3
View File
@@ -71,8 +71,10 @@ a{color:var(--accent)}
td form{display:inline;margin:0} td form{display:inline;margin:0}
td button{width:auto;margin:0 .25rem .25rem 0;padding:.3rem .5rem;font-size:.8rem;font-weight:500; td button{width:auto;margin:0 .25rem .25rem 0;padding:.3rem .5rem;font-size:.8rem;font-weight:500;
background:transparent;color:var(--accent);border:1px solid var(--line)} background:transparent;color:var(--accent);border:1px solid var(--line)}
td button.danger{color:#b02a37} td button.danger,a.danger{color:#b02a37}
@media(prefers-color-scheme:dark){td button.danger{color:#f2b8c0}} button.destroy{background:#b02a37}
@media(prefers-color-scheme:dark){td button.danger,a.danger{color:#f2b8c0}
button.destroy{background:#a3313c}}
.inline{display:flex;gap:.6rem;align-items:flex-end;flex-wrap:wrap;margin:0} .inline{display:flex;gap:.6rem;align-items:flex-end;flex-wrap:wrap;margin:0}
.inline>div{flex:1 1 9rem} .inline>div{flex:1 1 9rem}
.inline label{margin-top:0} .inline label{margin-top:0}
@@ -133,7 +135,7 @@ var vaultsTmpl = template.Must(template.Must(pages.Clone()).Parse(`
{{if .Notice}}<p class="ok">{{.Notice}}</p>{{end}} {{if .Notice}}<p class="ok">{{.Notice}}</p>{{end}}
{{if .Vaults}} {{if .Vaults}}
<div class="scroll"><table> <div class="scroll"><table>
<thead><tr><th>Vault</th><th>Access</th><th>On conflict</th><th>Version</th><th>Last saved</th></tr></thead> <thead><tr><th>Vault</th><th>Access</th><th>On conflict</th><th>Version</th><th>Last saved</th><th></th></tr></thead>
<tbody> <tbody>
{{range .Vaults}}<tr> {{range .Vaults}}<tr>
<td class="name">{{.Name}}.kdbx</td> <td class="name">{{.Name}}.kdbx</td>
@@ -141,6 +143,7 @@ var vaultsTmpl = template.Must(template.Must(pages.Clone()).Parse(`
<td><span class="tag">{{.Strategy}}</span></td> <td><span class="tag">{{.Strategy}}</span></td>
<td>{{.Version}}</td> <td>{{.Version}}</td>
<td>{{.Updated}}{{if .By}}<br><small>by {{.By}}</small>{{end}}</td> <td>{{.Updated}}{{if .By}}<br><small>by {{.By}}</small>{{end}}</td>
<td>{{if eq .Perm "admin"}}<a class="danger" href="/web/vaults/{{.ID}}/delete">Delete</a>{{end}}</td>
</tr>{{end}} </tr>{{end}}
</tbody></table></div> </tbody></table></div>
{{else}} {{else}}
@@ -201,6 +204,51 @@ Changing it signs out your other sessions.</p>
</details> </details>
{{end}}`)) {{end}}`))
var deleteVaultTmpl = template.Must(template.Must(pages.Clone()).Parse(`
{{define "body"}}
{{with .V}}
<h1>Delete {{.Name}}.kdbx?</h1>
<p class="sub">This cannot be undone. <a href="/web/vaults">Back to your vaults</a></p>
{{end}}
{{if .Error}}<p class="err">{{.Error}}</p>{{end}}
{{with .V}}
<div class="scroll"><table>
<tbody>
<tr><td class="name">Owned by</td><td>{{.Group}}</td></tr>
<tr><td class="name">Versions stored</td><td>{{.Version}}</td></tr>
<tr><td class="name">Last saved</td><td>{{.Updated}}</td></tr>
<tr><td class="name">Groups granted access</td><td>{{.Grants}}</td></tr>
</tbody></table></div>
<p class="note">Every stored version of this file is erased from the server,
along with the grants that point at it. Copies already in people's mirror
directories stay on their computers: the server stops tracking the vault, it
does not reach into anybody's home directory. If the vault is merged here,
the key the server holds goes with it.</p>
<fieldset><legend>Confirm it is you</legend>
<form method="post" action="/web/vaults/{{.ID}}/delete">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
{{if .NeedsName}}
<label for="confirm_name">Type the vault's name: <strong>{{.Name}}</strong></label>
<input id="confirm_name" name="confirm_name" required autocomplete="off" autofocus>
<p class="sub">You sign in through single sign-on, so there is no password here
to ask for.</p>
{{else}}
<label for="password">Your account password</label>
<input id="password" name="password" type="password" autocomplete="current-password" required autofocus>
{{if .NeedsTOTP}}
<label for="totp">One-time code</label>
<input id="totp" name="totp" inputmode="numeric" autocomplete="one-time-code" required>
{{end}}
<p class="sub">Not the vault's password — the one you signed in with.</p>
{{end}}
<button class="destroy" type="submit">Delete {{.Name}} permanently</button>
</form>
</fieldset>
{{end}}
{{end}}`))
var doneTmpl = template.Must(template.Must(pages.Clone()).Parse(` var doneTmpl = template.Must(template.Must(pages.Clone()).Parse(`
{{define "body"}}<h1>{{.Title}}</h1><p class="sub">{{.Message}}</p>{{end}}`)) {{define "body"}}<h1>{{.Title}}</h1><p class="sub">{{.Message}}</p>{{end}}`))
@@ -227,6 +275,17 @@ func (s *Server) renderVaults(w http.ResponseWriter, sess *webSession, errMsg, n
render(w, vaultsTmpl, d) render(w, vaultsTmpl, d)
} }
func (s *Server) renderDeleteVault(w http.ResponseWriter, sess *webSession, id, errMsg string) {
v := s.deleteVaultView(sess.user, id)
if v == nil {
s.renderVaults(w, sess, "There is no such vault, or you do not administer it.", "")
return
}
d := s.shell(sess, "Delete vault", "Vaults")
d["V"], d["Error"] = v, errMsg
render(w, deleteVaultTmpl, d)
}
func (s *Server) renderDone(w http.ResponseWriter, title, msg string) { func (s *Server) renderDone(w http.ResponseWriter, title, msg string) {
render(w, doneTmpl, map[string]any{"Title": title, "Message": msg}) render(w, doneTmpl, map[string]any{"Title": title, "Message": msg})
} }
+2
View File
@@ -192,6 +192,8 @@ func (s *Server) routes() {
m.HandleFunc("GET /web/vaults", s.webPage(s.handleWebVaults)) m.HandleFunc("GET /web/vaults", s.webPage(s.handleWebVaults))
m.HandleFunc("POST /web/vaults", s.webForm(s.handleWebCreateVault)) m.HandleFunc("POST /web/vaults", s.webForm(s.handleWebCreateVault))
m.HandleFunc("POST /web/password", s.webForm(s.handleWebChangePassword)) m.HandleFunc("POST /web/password", s.webForm(s.handleWebChangePassword))
m.HandleFunc("GET /web/vaults/{id}/delete", s.webPage(s.handleWebDeleteVaultPage))
m.HandleFunc("POST /web/vaults/{id}/delete", s.webForm(s.handleWebDeleteVault))
m.HandleFunc("GET /web/groups", s.webPage(s.handleWebGroups)) m.HandleFunc("GET /web/groups", s.webPage(s.handleWebGroups))
m.HandleFunc("POST /web/groups", s.webForm(s.handleWebCreateGroup)) m.HandleFunc("POST /web/groups", s.webForm(s.handleWebCreateGroup))
m.HandleFunc("GET /web/groups/{id}", s.webPage(s.handleWebGroup)) m.HandleFunc("GET /web/groups/{id}", s.webPage(s.handleWebGroup))
+70
View File
@@ -217,6 +217,76 @@ func (s *Server) handleWebChangePassword(w http.ResponseWriter, r *http.Request,
} }
} }
// webDeleteVault is what the confirmation page shows before a vault goes.
type webDeleteVault struct {
ID string
Name string
Group string
Version int64
Updated string
Grants int
// NeedsName marks an account with no password of its own, which confirms
// by typing the vault's name instead; NeedsTOTP marks one with a second
// factor enrolled.
NeedsName bool
NeedsTOTP bool
}
// deleteVaultView gathers what is about to be destroyed, or nil when the
// caller does not administer the vault.
func (s *Server) deleteVaultView(u *store.User, id string) *webDeleteVault {
var out *webDeleteVault
s.st.Read(func(st *store.State) {
db := st.Databases[id]
if db == nil || st.DatabasePerm(u.ID, id) != store.PermAdmin {
return
}
v := &webDeleteVault{ID: db.ID, Name: db.Name, Version: db.Version, Updated: "never"}
if db.Version > 0 {
v.Updated = db.UpdatedAt.UTC().Format("2006-01-02 15:04 UTC")
}
if g := st.Groups[db.OwnerGroupID]; g != nil {
v.Group = g.Name
}
for _, gr := range st.Grants {
if gr.DatabaseID == id {
v.Grants++
}
}
if target := st.Users[u.ID]; target != nil {
v.NeedsName = target.PassHash == ""
v.NeedsTOTP = target.TOTPConfirmed
}
out = v
})
return out
}
func (s *Server) handleWebDeleteVaultPage(w http.ResponseWriter, r *http.Request, sess *webSession) {
s.renderDeleteVault(w, sess, r.PathValue("id"), "")
}
func (s *Server) handleWebDeleteVault(w http.ResponseWriter, r *http.Request, sess *webSession) {
id := r.PathValue("id")
name, err := s.deleteDatabase(sess.user, id, r.FormValue("password"),
r.FormValue("totp"), r.FormValue("confirm_name"))
switch {
case errors.Is(err, errTOTPRequired):
s.renderDeleteVault(w, sess, id, "Enter your one-time code as well.")
case errors.Is(err, errTOTPInvalid):
s.renderDeleteVault(w, sess, id, "That one-time code was rejected.")
case errors.Is(err, errBadCredentials):
s.renderDeleteVault(w, sess, id, "That is not your account password.")
case errors.Is(err, errConfirmName):
s.renderDeleteVault(w, sess, id, "Type the vault's name exactly as it is shown.")
case err != nil:
s.renderDeleteVault(w, sess, id, humanError(err))
default:
s.renderVaults(w, sess, "", "Deleted "+name+" and every version of it. "+
"Copies already on people's computers stay where they are.")
}
}
// humanError keeps internal wording out of the browser. // humanError keeps internal wording out of the browser.
func humanError(err error) string { func humanError(err error) string {
if errors.Is(err, errForbidden) { if errors.Is(err, errForbidden) {
+6
View File
@@ -1,3 +1,9 @@
keeporg (0.4.0) unstable; urgency=medium
* keeporg db -delete now asks for your account password
-- tiredsysadmin <rcmd@garage.rcmd.space> Fri, 30 Oct 2026 12:54:05 +0100
keeporg (0.3.0) unstable; urgency=medium keeporg (0.3.0) unstable; urgency=medium
* New `keeporg passwd` command, which changes the account password. * New `keeporg passwd` command, which changes the account password.
+6
View File
@@ -1,3 +1,9 @@
keeporgd (0.7.0) unstable; urgency=medium
* Deleting a database now requires the caller's own account password
-- tiredsysadmin <rcmd@garage.rcmd.space> Fri, 30 Oct 2026 12:54:05 +0100
keeporgd (0.6.0) unstable; urgency=medium keeporgd (0.6.0) unstable; urgency=medium
* Add the administration pages: groups and their members, accounts, * Add the administration pages: groups and their members, accounts,
+5 -5
View File
@@ -1,10 +1,10 @@
# Single source of truth for all platform versions (semver). # Single source of truth for all platform versions (semver).
# Bump with: make bump PLATFORM=<server|linux|macos|windows|bsd|android> PART=<major|minor|patch> # Bump with: make bump PLATFORM=<server|linux|macos|windows|bsd|android> PART=<major|minor|patch>
VERSION_SERVER := 0.6.0 VERSION_SERVER := 0.7.0
VERSION_LINUX := 0.3.0 VERSION_LINUX := 0.4.0
VERSION_MACOS := 0.3.0 VERSION_MACOS := 0.4.0
VERSION_WINDOWS := 0.3.0 VERSION_WINDOWS := 0.4.0
VERSION_BSD := 0.3.0 VERSION_BSD := 0.4.0
VERSION_ANDROID := 0.2.0 VERSION_ANDROID := 0.2.0
# Monotonic Android versionCode, never decreases. # Monotonic Android versionCode, never decreases.
ANDROID_VERSION_CODE := 2 ANDROID_VERSION_CODE := 2