From 4bb9e84c352dcc2b879f25bca54b5d4833477d13 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Mon, 29 Jun 2026 21:42:10 +0800 Subject: [PATCH] feat: harden secret unlock and deletion --- .../src/lib/plugin-host/VerstakPluginAPI.js | 9 +++ frontend/wailsjs/go/api/App.d.ts | 30 +++++----- frontend/wailsjs/go/api/App.js | 60 ++++++++++--------- internal/api/app.go | 21 ++++++- internal/api/app_test.go | 21 +++++++ internal/core/secrets/store.go | 31 ++++++++++ internal/core/secrets/store_test.go | 51 ++++++++++++++++ 7 files changed, 180 insertions(+), 43 deletions(-) diff --git a/frontend/src/lib/plugin-host/VerstakPluginAPI.js b/frontend/src/lib/plugin-host/VerstakPluginAPI.js index b3c00fb..66525d8 100644 --- a/frontend/src/lib/plugin-host/VerstakPluginAPI.js +++ b/frontend/src/lib/plugin-host/VerstakPluginAPI.js @@ -293,6 +293,15 @@ export function createPluginAPI(pluginId) { return App.PluginSecretsWrite(pluginId, record || {}); }); }, + delete: function(secretId) { + assertActive('secrets.delete(' + secretId + ')'); + if (!secretId) { + throw new Error('secrets.delete requires a secret id'); + } + return callBackendErrorString(pluginId, 'secrets.delete(' + secretId + ')', function() { + return App.PluginSecretsDelete(pluginId, secretId); + }); + }, copyLink: function(secretId) { assertActive('secrets.copyLink(' + secretId + ')'); if (!secretId) { diff --git a/frontend/wailsjs/go/api/App.d.ts b/frontend/wailsjs/go/api/App.d.ts index 7b0a092..efdcb52 100755 --- a/frontend/wailsjs/go/api/App.d.ts +++ b/frontend/wailsjs/go/api/App.d.ts @@ -80,6 +80,20 @@ export function OpenVaultPathExternal(arg1:string,arg2:string):Promise; export function OpenWorkbenchResource(arg1:string,arg2:Record):Promise; +export function PluginSecretsCopyLink(arg1:string,arg2:string):Promise; + +export function PluginSecretsDelete(arg1:string,arg2:string):Promise; + +export function PluginSecretsList(arg1:string):Promise>|string>; + +export function PluginSecretsRead(arg1:string,arg2:string):Promise|string>; + +export function PluginSecretsStatus(arg1:string):Promise|string>; + +export function PluginSecretsUnlock(arg1:string,arg2:string):Promise; + +export function PluginSecretsWrite(arg1:string,arg2:Record):Promise|string>; + export function PluginSyncConfigure(arg1:string,arg2:string,arg3:string,arg4:string):Promise; export function PluginSyncDisconnect(arg1:string):Promise; @@ -94,18 +108,6 @@ export function PluginSyncStatus(arg1:string):Promise; export function PluginSyncTestConnection(arg1:string,arg2:string,arg3:string,arg4:string):Promise; -export function PluginSecretsCopyLink(arg1:string,arg2:string):Promise; - -export function PluginSecretsList(arg1:string):Promise>|string>; - -export function PluginSecretsRead(arg1:string,arg2:string):Promise|string>; - -export function PluginSecretsStatus(arg1:string):Promise|string>; - -export function PluginSecretsUnlock(arg1:string,arg2:string):Promise; - -export function PluginSecretsWrite(arg1:string,arg2:Record):Promise|string>; - export function PublishPluginEvent(arg1:string,arg2:string,arg3:Record):Promise; export function ReadPluginDataJSON(arg1:string,arg2:string):Promise>; @@ -160,6 +162,6 @@ export function WritePluginSetting(arg1:string,arg2:string,arg3:any):Promise):Promise; -export function WriteVaultTextFile(arg1:string,arg2:string,arg3:string,arg4:files.WriteOptions):Promise; - export function WriteVaultFileBytes(arg1:string,arg2:string,arg3:string,arg4:files.WriteOptions):Promise; + +export function WriteVaultTextFile(arg1:string,arg2:string,arg3:string,arg4:files.WriteOptions):Promise; diff --git a/frontend/wailsjs/go/api/App.js b/frontend/wailsjs/go/api/App.js index 544646d..5a8a35b 100755 --- a/frontend/wailsjs/go/api/App.js +++ b/frontend/wailsjs/go/api/App.js @@ -146,6 +146,34 @@ export function OpenWorkbenchResource(arg1, arg2) { return window['go']['api']['App']['OpenWorkbenchResource'](arg1, arg2); } +export function PluginSecretsCopyLink(arg1, arg2) { + return window['go']['api']['App']['PluginSecretsCopyLink'](arg1, arg2); +} + +export function PluginSecretsDelete(arg1, arg2) { + return window['go']['api']['App']['PluginSecretsDelete'](arg1, arg2); +} + +export function PluginSecretsList(arg1) { + return window['go']['api']['App']['PluginSecretsList'](arg1); +} + +export function PluginSecretsRead(arg1, arg2) { + return window['go']['api']['App']['PluginSecretsRead'](arg1, arg2); +} + +export function PluginSecretsStatus(arg1) { + return window['go']['api']['App']['PluginSecretsStatus'](arg1); +} + +export function PluginSecretsUnlock(arg1, arg2) { + return window['go']['api']['App']['PluginSecretsUnlock'](arg1, arg2); +} + +export function PluginSecretsWrite(arg1, arg2) { + return window['go']['api']['App']['PluginSecretsWrite'](arg1, arg2); +} + export function PluginSyncConfigure(arg1, arg2, arg3, arg4) { return window['go']['api']['App']['PluginSyncConfigure'](arg1, arg2, arg3, arg4); } @@ -174,30 +202,6 @@ export function PluginSyncTestConnection(arg1, arg2, arg3, arg4) { return window['go']['api']['App']['PluginSyncTestConnection'](arg1, arg2, arg3, arg4); } -export function PluginSecretsCopyLink(arg1, arg2) { - return window['go']['api']['App']['PluginSecretsCopyLink'](arg1, arg2); -} - -export function PluginSecretsList(arg1) { - return window['go']['api']['App']['PluginSecretsList'](arg1); -} - -export function PluginSecretsRead(arg1, arg2) { - return window['go']['api']['App']['PluginSecretsRead'](arg1, arg2); -} - -export function PluginSecretsStatus(arg1) { - return window['go']['api']['App']['PluginSecretsStatus'](arg1); -} - -export function PluginSecretsUnlock(arg1, arg2) { - return window['go']['api']['App']['PluginSecretsUnlock'](arg1, arg2); -} - -export function PluginSecretsWrite(arg1, arg2) { - return window['go']['api']['App']['PluginSecretsWrite'](arg1, arg2); -} - export function PublishPluginEvent(arg1, arg2, arg3) { return window['go']['api']['App']['PublishPluginEvent'](arg1, arg2, arg3); } @@ -306,10 +310,10 @@ export function WritePluginSettings(arg1, arg2) { return window['go']['api']['App']['WritePluginSettings'](arg1, arg2); } -export function WriteVaultTextFile(arg1, arg2, arg3, arg4) { - return window['go']['api']['App']['WriteVaultTextFile'](arg1, arg2, arg3, arg4); -} - export function WriteVaultFileBytes(arg1, arg2, arg3, arg4) { return window['go']['api']['App']['WriteVaultFileBytes'](arg1, arg2, arg3, arg4); } + +export function WriteVaultTextFile(arg1, arg2, arg3, arg4) { + return window['go']['api']['App']['WriteVaultTextFile'](arg1, arg2, arg3, arg4); +} diff --git a/internal/api/app.go b/internal/api/app.go index 390fb79..15d41fb 100644 --- a/internal/api/app.go +++ b/internal/api/app.go @@ -1322,8 +1322,13 @@ func (a *App) PluginSecretsStatus(pluginID string) (map[string]interface{}, stri if err != nil { return nil, err.Error() } + initialized, err := session.Initialized() + if err != nil { + return nil, err.Error() + } return map[string]interface{}{ - "unlocked": session.Unlocked(), + "initialized": initialized, + "unlocked": session.Unlocked(), }, "" } @@ -1397,6 +1402,20 @@ func (a *App) PluginSecretsWrite(pluginID string, rawRecord map[string]interface return secretRecordMap(written, false), "" } +func (a *App) PluginSecretsDelete(pluginID, secretID string) string { + if err := a.requirePluginSecretsAccess(pluginID, true); err != nil { + return err.Error() + } + store, err := a.requireUnlockedSecretStore() + if err != nil { + return err.Error() + } + if err := store.Delete(secretID); err != nil { + return err.Error() + } + return "" +} + func (a *App) PluginSecretsCopyLink(pluginID, secretID string) (string, string) { if err := a.requirePluginSecretsAccess(pluginID, false); err != nil { return "", err.Error() diff --git a/internal/api/app_test.go b/internal/api/app_test.go index 785272c..25a6810 100644 --- a/internal/api/app_test.go +++ b/internal/api/app_test.go @@ -1628,6 +1628,9 @@ func TestPluginSecretsRequirePermissionsAndUnlock(t *testing.T) { if status["unlocked"] == true { t.Fatalf("new secret session should be locked: %+v", status) } + if status["initialized"] == true { + t.Fatalf("new secret session should not be initialized: %+v", status) + } if errStr := app.PluginSecretsUnlock("no.storage", "master password"); !strings.Contains(errStr, "secrets.read") { t.Fatalf("PluginSecretsUnlock err = %q, want secrets.read permission error", errStr) @@ -1636,6 +1639,10 @@ func TestPluginSecretsRequirePermissionsAndUnlock(t *testing.T) { t.Fatalf("PluginSecretsList before unlock err = %q, want locked", errStr) } + if errStr := app.PluginSecretsUnlock("secrets.plugin", "123123"); !strings.Contains(errStr, "at least 8 characters") { + t.Fatalf("weak PluginSecretsUnlock err = %q, want minimum length error", errStr) + } + if errStr := app.PluginSecretsUnlock("secrets.plugin", "master password"); errStr != "" { t.Fatalf("PluginSecretsUnlock: %s", errStr) } @@ -1646,6 +1653,9 @@ func TestPluginSecretsRequirePermissionsAndUnlock(t *testing.T) { if status["unlocked"] != true { t.Fatalf("secret session not unlocked: %+v", status) } + if status["initialized"] != true { + t.Fatalf("secret session not initialized: %+v", status) + } writeResult, errStr := app.PluginSecretsWrite("secrets.plugin", map[string]interface{}{ "id": "client-a.database", @@ -1694,6 +1704,17 @@ func TestPluginSecretsRequirePermissionsAndUnlock(t *testing.T) { if link != "[Client A Database](verstak-secret://client-a.database)" { t.Fatalf("link = %q", link) } + + if errStr := app.PluginSecretsDelete("secrets.plugin", "client-a.database"); errStr != "" { + t.Fatalf("PluginSecretsDelete: %s", errStr) + } + list, errStr = app.PluginSecretsList("secrets.plugin") + if errStr != "" { + t.Fatalf("PluginSecretsList after delete: %s", errStr) + } + if len(list) != 0 { + t.Fatalf("PluginSecretsList after delete = %+v, want empty", list) + } } func TestPluginSecretsRejectWrongMasterPasswordAcrossSessions(t *testing.T) { diff --git a/internal/core/secrets/store.go b/internal/core/secrets/store.go index 840d61a..ae9385e 100644 --- a/internal/core/secrets/store.go +++ b/internal/core/secrets/store.go @@ -25,6 +25,7 @@ const ( masterPBKDF2Iterations = 200000 masterVerifierPlaintext = "verstak-secret-store:v1" masterMetadataVersion = 1 + minMasterPasswordLength = 8 recordsDirName = "records" masterMetadataFileName = "metadata.json" ScopeGlobal = "global" @@ -184,6 +185,18 @@ func (s *Store) ReadRecord(id string) (SecretRecord, error) { return decoded, nil } +func (s *Store) Delete(id string) error { + if err := validateID(id); err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + if err := os.Remove(s.pathForID(id)); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("delete secret %q: %w", id, err) + } + return nil +} + func (s *Store) ListRecords() ([]SecretRecord, error) { s.mu.RLock() entries, err := os.ReadDir(s.root) @@ -350,6 +363,14 @@ func (s *VaultSession) Store() (*Store, error) { return s.store, nil } +func (s *VaultSession) Initialized() (bool, error) { + metadata, err := readMasterMetadata(s.root) + if err != nil { + return false, err + } + return metadata != nil, nil +} + func (s *VaultSession) Unlock(masterPassword string) (*Store, error) { if strings.TrimSpace(masterPassword) == "" { return nil, fmt.Errorf("master password is empty") @@ -366,6 +387,9 @@ func (s *VaultSession) Unlock(masterPassword string) (*Store, error) { return nil, err } if metadata == nil { + if err := validateInitialMasterPassword(masterPassword); err != nil { + return nil, err + } created, key, err := createMasterMetadata(masterPassword) if err != nil { return nil, err @@ -393,6 +417,13 @@ func (s *VaultSession) Unlock(masterPassword string) (*Store, error) { return store, nil } +func validateInitialMasterPassword(masterPassword string) error { + if len([]rune(masterPassword)) < minMasterPasswordLength { + return fmt.Errorf("master password must be at least %d characters", minMasterPasswordLength) + } + return nil +} + func readMasterMetadata(root string) (*masterMetadata, error) { data, err := os.ReadFile(filepath.Join(root, masterMetadataFileName)) if err != nil { diff --git a/internal/core/secrets/store_test.go b/internal/core/secrets/store_test.go index cf53773..7fbac45 100644 --- a/internal/core/secrets/store_test.go +++ b/internal/core/secrets/store_test.go @@ -106,6 +106,30 @@ func TestStoreRejectsUnsafeIDs(t *testing.T) { } } +func TestStoreDeletesSecretRecord(t *testing.T) { + store, err := NewStore(t.TempDir(), testKey(0x11)) + if err != nil { + t.Fatalf("NewStore: %v", err) + } + if err := store.Write("server.password", "s3cr3t-value"); err != nil { + t.Fatalf("Write: %v", err) + } + + if err := store.Delete("server.password"); err != nil { + t.Fatalf("Delete: %v", err) + } + if _, err := store.Read("server.password"); err == nil { + t.Fatal("Read after Delete succeeded") + } + list, err := store.ListRecords() + if err != nil { + t.Fatalf("ListRecords: %v", err) + } + if len(list) != 0 { + t.Fatalf("ListRecords after Delete = %+v, want empty", list) + } +} + func TestStoreListsScopedRecordsWithoutPlaintextOnDisk(t *testing.T) { root := t.TempDir() store, err := NewStore(root, testKey(0x11)) @@ -224,3 +248,30 @@ func TestVaultSessionUnlocksWithMasterPasswordOnce(t *testing.T) { } } } + +func TestVaultSessionReportsInitializationAndRejectsWeakInitialPassword(t *testing.T) { + root := t.TempDir() + session := NewVaultSession(root) + + initialized, err := session.Initialized() + if err != nil { + t.Fatalf("Initialized: %v", err) + } + if initialized { + t.Fatal("new secret session is initialized") + } + if _, err := session.Unlock("123123"); err == nil || !strings.Contains(err.Error(), "at least 8 characters") { + t.Fatalf("weak initial Unlock err = %v, want minimum length error", err) + } + + if _, err := session.Unlock("strong password"); err != nil { + t.Fatalf("Unlock strong initial password: %v", err) + } + initialized, err = session.Initialized() + if err != nil { + t.Fatalf("Initialized after unlock: %v", err) + } + if !initialized { + t.Fatal("secret session was not initialized") + } +}