Skip to content

Commit ec31d07

Browse files
committed
feat: Add del/delete command for personal and vault secrets with confirmation prompt and audit logging
1 parent 072d096 commit ec31d07

File tree

4 files changed

+365
-3
lines changed

4 files changed

+365
-3
lines changed

internal/command/command.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const (
2828
OpVaultDestroy
2929
OpVaultRevoke
3030
OpMove
31+
OpDelete
3132
)
3233

3334
// String returns the lowercase name of the operation.
@@ -65,6 +66,8 @@ func (op Op) String() string {
6566
return "vault revoke"
6667
case OpMove:
6768
return "move"
69+
case OpDelete:
70+
return "del"
6871
default:
6972
return "unknown"
7073
}
@@ -122,6 +125,16 @@ func Parse(argv []string) (Command, error) {
122125
}
123126
return parseListArg(args[0])
124127

128+
case "del", "delete":
129+
if len(args) != 1 {
130+
return Command{}, fmt.Errorf("del requires exactly one path argument")
131+
}
132+
vault, p, err := parseVaultPath(args[0])
133+
if err != nil {
134+
return Command{}, err
135+
}
136+
return Command{Op: OpDelete, Path: p, Vault: vault}, nil
137+
125138
case "invite":
126139
if len(args) != 0 {
127140
return Command{}, fmt.Errorf("invite takes no arguments")

internal/command/command_test.go

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,23 @@ func TestParse(t *testing.T) {
8080
want: command.Command{Op: command.OpHelp},
8181
},
8282
{
83-
name: "unknown command",
84-
argv: []string{"delete", "account/github"},
83+
name: "del command",
84+
argv: []string{"del", "account/github"},
85+
want: command.Command{Op: command.OpDelete, Path: "account/github"},
86+
},
87+
{
88+
name: "delete alias",
89+
argv: []string{"delete", "account/github"},
90+
want: command.Command{Op: command.OpDelete, Path: "account/github"},
91+
},
92+
{
93+
name: "del without path",
94+
argv: []string{"del"},
95+
wantErr: true,
96+
},
97+
{
98+
name: "del with extra args",
99+
argv: []string{"del", "account/github", "extra"},
85100
wantErr: true,
86101
},
87102
{
@@ -240,6 +255,21 @@ func TestParseVaultSyntax(t *testing.T) {
240255
argv: []string{"get", "personal:foo"},
241256
want: command.Command{Op: command.OpGet, Path: "foo", Vault: ""},
242257
},
258+
{
259+
name: "del with vault prefix",
260+
argv: []string{"del", "tv:secret"},
261+
want: command.Command{Op: command.OpDelete, Path: "secret", Vault: "tv"},
262+
},
263+
{
264+
name: "del with personal prefix",
265+
argv: []string{"del", "personal:secret"},
266+
want: command.Command{Op: command.OpDelete, Path: "secret", Vault: ""},
267+
},
268+
{
269+
name: "delete with vault prefix",
270+
argv: []string{"delete", "tv:foo/bar"},
271+
want: command.Command{Op: command.OpDelete, Path: "foo/bar", Vault: "tv"},
272+
},
243273
// Vault management commands
244274
{
245275
name: "vault create",

internal/command/handler.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,11 @@ func (h *Handler) Handle(sess ssh.Session, username string, pubKey gossh.PublicK
106106
return h.handleVaultListAll(sess, username)
107107
case OpVaultDestroy:
108108
return h.handleVaultDestroy(sess, username, cmd.Vault)
109+
case OpDelete:
110+
if cmd.Vault != "" {
111+
return h.handleVaultDelete(sess, username, pubKey, cmd.Vault, cmd.Path)
112+
}
113+
return h.handleDelete(sess, username, pubKey, cmd.Path)
109114
case OpMove:
110115
return h.handleMove(sess, username, pubKey, cmd)
111116
case OpHelp:
@@ -233,6 +238,96 @@ func FormatPath(p string, color bool) string {
233238
return blue + p[:idx+1] + reset + p[idx+1:]
234239
}
235240

241+
func (h *Handler) handleDelete(sess ssh.Session, username string, pubKey gossh.PublicKey, path string) error {
242+
ag, cleanup, err := requireAgent(sess)
243+
if err != nil {
244+
return err
245+
}
246+
defer cleanup()
247+
248+
// Verify the secret exists and is decryptable before prompting for confirmation
249+
ciphertext, err := h.store.Read(username, path)
250+
if err != nil {
251+
return fmt.Errorf("read secret: %w", err)
252+
}
253+
254+
plaintext, err := h.enc.DecryptAndUpgrade(ag, pubKey, h.serverSecret, username, path, ciphertext, func(upgraded []byte) error {
255+
return h.store.Write(username, path, upgraded)
256+
})
257+
if err != nil {
258+
return fmt.Errorf("decrypt: %w", err)
259+
}
260+
crypto.Zeroize(plaintext)
261+
262+
fmt.Fprintf(sess, "Delete %s? [y/N]: ", path)
263+
buf := make([]byte, 1)
264+
n, err := sess.Read(buf)
265+
if err != nil || n == 0 || (buf[0] != 'y' && buf[0] != 'Y') {
266+
fmt.Fprintln(sess, "Delete cancelled.")
267+
return nil
268+
}
269+
270+
if err := h.store.Delete(username, path); err != nil {
271+
return fmt.Errorf("delete secret: %w", err)
272+
}
273+
274+
fmt.Fprintln(sess, "Deleted.")
275+
return nil
276+
}
277+
278+
func (h *Handler) handleVaultDelete(sess ssh.Session, username string, pubKey gossh.PublicKey, vaultName, path string) error {
279+
ag, cleanup, err := requireAgent(sess)
280+
if err != nil {
281+
return err
282+
}
283+
defer cleanup()
284+
285+
if !h.vaultMgr.HasAccess(vaultName, username) {
286+
if h.auditLog != nil {
287+
h.auditLog.VaultOpDenied("del", username, sess.RemoteAddr().String(), vaultName, "not a member")
288+
}
289+
return fmt.Errorf("permission denied: not a member of vault %q", vaultName)
290+
}
291+
292+
// Verify the secret exists and is decryptable before prompting for confirmation
293+
vaultKey, err := h.vaultMgr.VaultKey(vaultName, username, ag, pubKey)
294+
if err != nil {
295+
return fmt.Errorf("vault key: %w", err)
296+
}
297+
defer crypto.Zeroize(vaultKey)
298+
299+
ciphertext, err := h.fileStore.ReadVaultSecret(vaultName, path)
300+
if err != nil {
301+
return fmt.Errorf("read secret: %w", err)
302+
}
303+
304+
plaintext, err := decryptVaultSecret(vaultKey, path, h.serverSecret, ciphertext, func(upgraded []byte) error {
305+
return h.fileStore.WriteVaultSecret(vaultName, path, upgraded)
306+
})
307+
if err != nil {
308+
return fmt.Errorf("decrypt: %w", err)
309+
}
310+
crypto.Zeroize(plaintext)
311+
312+
fmt.Fprintf(sess, "Delete %s:%s? [y/N]: ", vaultName, path)
313+
buf := make([]byte, 1)
314+
n, err := sess.Read(buf)
315+
if err != nil || n == 0 || (buf[0] != 'y' && buf[0] != 'Y') {
316+
fmt.Fprintln(sess, "Delete cancelled.")
317+
return nil
318+
}
319+
320+
if err := h.fileStore.DeleteVaultSecret(vaultName, path); err != nil {
321+
return fmt.Errorf("delete secret: %w", err)
322+
}
323+
324+
if h.auditLog != nil {
325+
h.auditLog.VaultOp("del", username, sess.RemoteAddr().String(), vaultName)
326+
}
327+
fmt.Fprintln(sess, "Deleted.")
328+
return nil
329+
}
330+
236331
func (h *Handler) handleVaultGet(sess ssh.Session, username string, pubKey gossh.PublicKey, vaultName, path string) error {
237332
ag, cleanup, err := requireAgent(sess)
238333
if err != nil {
@@ -266,6 +361,9 @@ func (h *Handler) handleVaultGet(sess ssh.Session, username string, pubKey gossh
266361
}
267362
defer crypto.Zeroize(plaintext)
268363

364+
if h.auditLog != nil {
365+
h.auditLog.VaultOp("get", username, sess.RemoteAddr().String(), vaultName)
366+
}
269367
_, err = sess.Write(plaintext)
270368
return err
271369
}
@@ -320,6 +418,9 @@ func (h *Handler) handleVaultSet(sess ssh.Session, username string, pubKey gossh
320418
return fmt.Errorf("write secret: %w", err)
321419
}
322420

421+
if h.auditLog != nil {
422+
h.auditLog.VaultOp("set", username, sess.RemoteAddr().String(), vaultName)
423+
}
323424
fmt.Fprintln(sess, "Secret stored.")
324425
return nil
325426
}
@@ -854,6 +955,7 @@ func helpText(color bool, version string) string {
854955
bold + "COMMANDS\n" + reset +
855956
cmd("get", yellow+"[vault:]<path>"+reset, "Decrypt and print a secret") +
856957
cmd("set", yellow+"[vault:]<path>"+reset, "Encrypt and store a secret") +
958+
cmd("del", yellow+"[vault:]<path>"+reset, "Delete a secret") +
857959
cmd("list", yellow+"[vault:][prefix]"+reset, "List secrets") +
858960
cmd("ls", yellow+"[vault:][prefix]"+reset, "Alias for list") +
859961
cmd("move", yellow+"<src> <dst>"+reset, "Move secret between vaults") +

0 commit comments

Comments
 (0)