Skip to content

Commit bb85c9b

Browse files
committed
feat: Allow owner to destroy a vault
1 parent a6004b4 commit bb85c9b

File tree

8 files changed

+162
-2
lines changed

8 files changed

+162
-2
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ Use colon syntax to target a vault: `vault:path`.
178178
| `vault accept <name> <token>` | Accept a vault invitation |
179179
| `vault promote <name> <user>` | Promote a member to admin (admin/owner) |
180180
| `vault members <name>` | List vault members and roles |
181+
| `vault destroy <name>` | Permanently destroy a vault (owner only) |
181182

182183
#### Administration
183184

docs/guide/vaults.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,14 @@ Promotes a `member` to `admin`.
4747
ssh -A alice@keys.example.com vault members team
4848
```
4949

50+
## Destroying a vault
51+
52+
```sh
53+
ssh -A alice@keys.example.com vault destroy team
54+
```
55+
56+
This permanently deletes the vault and all its secrets. Only the vault owner can destroy a vault. You will be prompted to type the vault name to confirm — this action cannot be undone.
57+
5058
## Vault secrets
5159

5260
Use colon syntax to target a vault — `vault:path`:

docs/reference/commands.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ Use colon syntax to target a vault: `vault:path`.
3333
| `vault accept <name> <token>` | Accept a vault invitation |
3434
| `vault promote <name> <user>` | Promote a member to admin (admin/owner) |
3535
| `vault members <name>` | List vault members and roles |
36+
| `vault destroy <name>` | Permanently destroy a vault (owner only)|
3637

3738
## Administration
3839

internal/command/command.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const (
2222
OpVaultPromote
2323
OpVaultMembers
2424
OpVaultList
25+
OpVaultDestroy
2526
OpMove
2627
)
2728

@@ -52,6 +53,8 @@ func (op Op) String() string {
5253
return "vault members"
5354
case OpVaultList:
5455
return "vault list"
56+
case OpVaultDestroy:
57+
return "vault destroy"
5558
case OpMove:
5659
return "move"
5760
default:
@@ -209,7 +212,7 @@ func parseListArg(arg string) (Command, error) {
209212
// parseVaultSubcommand parses "vault <subcommand> ..." args.
210213
func parseVaultSubcommand(args []string) (Command, error) {
211214
if len(args) == 0 {
212-
return Command{}, fmt.Errorf("vault requires a subcommand: create, invite, accept, promote, members, list")
215+
return Command{}, fmt.Errorf("vault requires a subcommand: create, invite, accept, promote, members, list, destroy")
213216
}
214217

215218
sub := args[0]
@@ -252,6 +255,12 @@ func parseVaultSubcommand(args []string) (Command, error) {
252255
}
253256
return Command{Op: OpVaultList}, nil
254257

258+
case "destroy":
259+
if len(subArgs) != 1 {
260+
return Command{}, fmt.Errorf("vault destroy requires exactly one name argument")
261+
}
262+
return Command{Op: OpVaultDestroy, Vault: subArgs[0]}, nil
263+
255264
default:
256265
return Command{}, fmt.Errorf("unknown vault subcommand %q", sub)
257266
}

internal/command/handler.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ func (h *Handler) Handle(sess ssh.Session, username string, pubKey gossh.PublicK
9090
return h.handleVaultMembers(sess, username, cmd.Vault)
9191
case OpVaultList:
9292
return h.handleVaultListAll(sess, username)
93+
case OpVaultDestroy:
94+
return h.handleVaultDestroy(sess, username, cmd.Vault)
9395
case OpMove:
9496
return h.handleMove(sess, username, pubKey, cmd)
9597
case OpHelp:
@@ -415,6 +417,33 @@ func (h *Handler) handleVaultListAll(sess ssh.Session, username string) error {
415417
return nil
416418
}
417419

420+
func (h *Handler) handleVaultDestroy(sess ssh.Session, username, vaultName string) error {
421+
if vaultName == "personal" {
422+
return fmt.Errorf("cannot destroy the personal vault")
423+
}
424+
425+
fmt.Fprintf(sess, "WARNING: This will permanently destroy vault %q and all its secrets.\n", vaultName)
426+
fmt.Fprintln(sess, "This action cannot be undone.")
427+
fmt.Fprintf(sess, "\nType the vault name to confirm: ")
428+
429+
confirmation, err := readLine(sess)
430+
if err != nil {
431+
return fmt.Errorf("read confirmation: %w", err)
432+
}
433+
434+
if strings.TrimSpace(string(confirmation)) != vaultName {
435+
fmt.Fprintln(sess, "Destroy cancelled.")
436+
return nil
437+
}
438+
439+
if err := h.vaultMgr.Destroy(vaultName, username); err != nil {
440+
return fmt.Errorf("destroy vault: %w", err)
441+
}
442+
443+
fmt.Fprintf(sess, "Vault %q destroyed.\n", vaultName)
444+
return nil
445+
}
446+
418447
func (h *Handler) handleMove(sess ssh.Session, username string, pubKey gossh.PublicKey, cmd Command) error {
419448
ag, cleanup, err := requireAgent(sess)
420449
if err != nil {
@@ -674,6 +703,7 @@ func helpText(color bool, version string) string {
674703
cmd2("vault accept", yellow+"<name> <token>"+reset, "Accept vault invite") +
675704
cmd2("vault promote", yellow+"<name> <user>"+reset, "Promote member to admin") +
676705
cmd2("vault members", yellow+"<name>"+reset, "List vault members") +
706+
cmd2("vault destroy", yellow+"<name>"+reset, "Permanently destroy a vault "+dim+"[owner]"+reset) +
677707
cmd2("vault list", "", "List your vaults") +
678708
"\n" +
679709
bold + "NOTES\n" + reset +

internal/server/server_test.go

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1082,14 +1082,104 @@ func TestHelpIncludesVersion(t *testing.T) {
10821082
}
10831083
}
10841084

1085+
func TestVaultDestroyByOwner(t *testing.T) {
1086+
addr, alice, _ := testServerSetupMultiUser(t)
1087+
1088+
// Create vault and set a secret
1089+
sshRun(t, addr, alice.cfg, alice.ag, "vault create doomed")
1090+
sshRunWithStdin(t, addr, alice.cfg, alice.ag, "set doomed:secret/key", "value")
1091+
1092+
// Destroy the vault — type vault name to confirm
1093+
out, err := sshRunWithStdin(t, addr, alice.cfg, alice.ag, "vault destroy doomed", "doomed\n")
1094+
if err != nil {
1095+
t.Fatalf("vault destroy: %v (output: %q)", err, out)
1096+
}
1097+
if !strings.Contains(out, "destroyed") {
1098+
t.Errorf("vault destroy output = %q, expected 'destroyed'", out)
1099+
}
1100+
1101+
// vault list should no longer include "doomed"
1102+
listOut, err := sshRun(t, addr, alice.cfg, alice.ag, "vault list")
1103+
if err != nil {
1104+
t.Fatalf("vault list: %v", err)
1105+
}
1106+
if strings.Contains(listOut, "doomed") {
1107+
t.Errorf("vault list still contains 'doomed' after destroy: %q", listOut)
1108+
}
1109+
1110+
// get on the vault secret should fail
1111+
_, err = sshRun(t, addr, alice.cfg, alice.ag, "get doomed:secret/key")
1112+
if err == nil {
1113+
t.Error("expected error getting secret from destroyed vault")
1114+
}
1115+
}
1116+
1117+
func TestVaultDestroyNonOwnerFails(t *testing.T) {
1118+
addr, alice, bob := testServerSetupMultiUser(t)
1119+
1120+
// Alice creates vault and invites Bob
1121+
sshRun(t, addr, alice.cfg, alice.ag, "vault create protected")
1122+
tokenOut, _ := sshRun(t, addr, alice.cfg, alice.ag, "vault invite protected bob")
1123+
token := strings.TrimSpace(tokenOut)
1124+
sshRun(t, addr, bob.cfg, bob.ag, "vault accept protected "+token)
1125+
1126+
// Bob (member) attempts destroy — should fail
1127+
_, err := sshRunWithStdin(t, addr, bob.cfg, bob.ag, "vault destroy protected", "protected\n")
1128+
if err == nil {
1129+
t.Error("expected error when non-owner tries to destroy vault")
1130+
}
1131+
1132+
// Vault should still exist
1133+
listOut, err := sshRun(t, addr, alice.cfg, alice.ag, "vault list")
1134+
if err != nil {
1135+
t.Fatalf("vault list: %v", err)
1136+
}
1137+
if !strings.Contains(listOut, "protected") {
1138+
t.Errorf("vault list missing 'protected' after failed destroy: %q", listOut)
1139+
}
1140+
}
1141+
1142+
func TestVaultDestroyCancelledOnMismatch(t *testing.T) {
1143+
addr, alice, _ := testServerSetupMultiUser(t)
1144+
1145+
sshRun(t, addr, alice.cfg, alice.ag, "vault create keepsafe")
1146+
1147+
// Send wrong name at confirmation
1148+
out, err := sshRunWithStdin(t, addr, alice.cfg, alice.ag, "vault destroy keepsafe", "wrong-name\n")
1149+
if err != nil {
1150+
t.Fatalf("vault destroy (mismatch): %v (output: %q)", err, out)
1151+
}
1152+
if !strings.Contains(out, "cancelled") {
1153+
t.Errorf("vault destroy mismatch output = %q, expected 'cancelled'", out)
1154+
}
1155+
1156+
// Vault should still exist
1157+
listOut, err := sshRun(t, addr, alice.cfg, alice.ag, "vault list")
1158+
if err != nil {
1159+
t.Fatalf("vault list: %v", err)
1160+
}
1161+
if !strings.Contains(listOut, "keepsafe") {
1162+
t.Errorf("vault list missing 'keepsafe' after cancelled destroy: %q", listOut)
1163+
}
1164+
}
1165+
1166+
func TestVaultDestroyPersonalRejected(t *testing.T) {
1167+
addr, alice := testServerSetup(t)
1168+
1169+
_, err := sshRunWithStdin(t, addr, alice.cfg, alice.ag, "vault destroy personal", "personal\n")
1170+
if err == nil {
1171+
t.Error("expected error when trying to destroy personal vault")
1172+
}
1173+
}
1174+
10851175
func TestHelpIncludesVaultCommands(t *testing.T) {
10861176
addr, alice := testServerSetup(t)
10871177

10881178
out, err := sshRun(t, addr, alice.cfg, alice.ag, "help")
10891179
if err != nil {
10901180
t.Fatalf("help: %v", err)
10911181
}
1092-
for _, want := range []string{"vault create", "vault invite", "vault accept", "vault promote", "vault members", "vault list", "move"} {
1182+
for _, want := range []string{"vault create", "vault invite", "vault accept", "vault promote", "vault members", "vault destroy", "vault list", "move"} {
10931183
if !strings.Contains(out, want) {
10941184
t.Errorf("help output missing %q", want)
10951185
}

internal/storage/vault_store.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,11 @@ func (s *FileStore) ListVaults() ([]string, error) {
204204
return vaults, nil
205205
}
206206

207+
// DeleteVault removes an entire vault directory and all its contents.
208+
func (s *FileStore) DeleteVault(vault string) error {
209+
return os.RemoveAll(s.vaultDir(vault))
210+
}
211+
207212
func (s *FileStore) vaultDir(vault string) string {
208213
return filepath.Join(s.dataDir, "vaults", vault)
209214
}

internal/vault/vault.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,22 @@ func (m *Manager) Promote(name, promoter, targetUser string) error {
290290
return m.store.WriteVaultMembers(name, membersJSON)
291291
}
292292

293+
// Destroy permanently deletes a vault. Only the vault owner can do this.
294+
func (m *Manager) Destroy(name, username string) error {
295+
data, err := m.store.ReadVaultMeta(name)
296+
if err != nil {
297+
return fmt.Errorf("read vault meta: %w", err)
298+
}
299+
var meta vaultMeta
300+
if err := json.Unmarshal(data, &meta); err != nil {
301+
return fmt.Errorf("unmarshal meta: %w", err)
302+
}
303+
if meta.Owner != username {
304+
return fmt.Errorf("only the vault owner can destroy a vault")
305+
}
306+
return m.store.DeleteVault(name)
307+
}
308+
293309
// deriveTokenKey derives an AES-256 key from an invite token using HKDF-SHA256.
294310
func deriveTokenKey(tokenBytes []byte) []byte {
295311
reader := hkdf.New(sha256.New, tokenBytes, nil, []byte("keyhole-vault-invite-v1"))

0 commit comments

Comments
 (0)