From 56c685212b6974846bf2bf35ba35b6647f7f87e8 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Mon, 29 Jun 2026 20:36:45 +0800 Subject: [PATCH] feat: expose scoped secret store API --- .../src/lib/plugin-host/VerstakPluginAPI.js | 47 +++ frontend/wailsjs/go/api/App.d.ts | 12 + frontend/wailsjs/go/api/App.js | 24 ++ internal/api/app.go | 166 ++++++++ internal/api/app_test.go | 105 ++++++ internal/core/secrets/store.go | 357 ++++++++++++++++-- internal/core/secrets/store_test.go | 119 ++++++ 7 files changed, 808 insertions(+), 22 deletions(-) diff --git a/frontend/src/lib/plugin-host/VerstakPluginAPI.js b/frontend/src/lib/plugin-host/VerstakPluginAPI.js index eef6881..b3c00fb 100644 --- a/frontend/src/lib/plugin-host/VerstakPluginAPI.js +++ b/frontend/src/lib/plugin-host/VerstakPluginAPI.js @@ -257,6 +257,53 @@ export function createPluginAPI(pluginId) { } }, + secrets: { + status: function() { + assertActive('secrets.status'); + return callBackend(pluginId, 'secrets.status', function() { + return App.PluginSecretsStatus(pluginId); + }); + }, + unlock: function(masterPassword) { + assertActive('secrets.unlock'); + return callBackendErrorString(pluginId, 'secrets.unlock', function() { + return App.PluginSecretsUnlock(pluginId, String(masterPassword == null ? '' : masterPassword)); + }); + }, + list: function() { + assertActive('secrets.list'); + return callBackend(pluginId, 'secrets.list', function() { + return App.PluginSecretsList(pluginId); + }).then(function(records) { + return Array.isArray(records) ? records : []; + }); + }, + read: function(secretId) { + assertActive('secrets.read(' + secretId + ')'); + if (!secretId) { + throw new Error('secrets.read requires a secret id'); + } + return callBackend(pluginId, 'secrets.read(' + secretId + ')', function() { + return App.PluginSecretsRead(pluginId, secretId); + }); + }, + write: function(record) { + assertActive('secrets.write'); + return callBackend(pluginId, 'secrets.write', function() { + return App.PluginSecretsWrite(pluginId, record || {}); + }); + }, + copyLink: function(secretId) { + assertActive('secrets.copyLink(' + secretId + ')'); + if (!secretId) { + throw new Error('secrets.copyLink requires a secret id'); + } + return callBackend(pluginId, 'secrets.copyLink(' + secretId + ')', function() { + return App.PluginSecretsCopyLink(pluginId, secretId); + }); + } + }, + files: { list: function(relativeDir) { assertActive('files.list'); diff --git a/frontend/wailsjs/go/api/App.d.ts b/frontend/wailsjs/go/api/App.d.ts index 7c93e49..7b0a092 100755 --- a/frontend/wailsjs/go/api/App.d.ts +++ b/frontend/wailsjs/go/api/App.d.ts @@ -94,6 +94,18 @@ 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>; diff --git a/frontend/wailsjs/go/api/App.js b/frontend/wailsjs/go/api/App.js index 8000b3c..544646d 100755 --- a/frontend/wailsjs/go/api/App.js +++ b/frontend/wailsjs/go/api/App.js @@ -174,6 +174,30 @@ 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); } diff --git a/internal/api/app.go b/internal/api/app.go index 5c6a48a..390fb79 100644 --- a/internal/api/app.go +++ b/internal/api/app.go @@ -24,6 +24,7 @@ import ( "github.com/verstak/verstak-desktop/internal/core/permissions" "github.com/verstak/verstak-desktop/internal/core/plugin" "github.com/verstak/verstak-desktop/internal/core/pluginstate" + coresecrets "github.com/verstak/verstak-desktop/internal/core/secrets" "github.com/verstak/verstak-desktop/internal/core/storage" syncsvc "github.com/verstak/verstak-desktop/internal/core/sync" "github.com/verstak/verstak-desktop/internal/core/vault" @@ -61,6 +62,7 @@ type App struct { workbench *coreworkbench.Router workspace *workspace.Manager syncSvc *syncsvc.Service + secretsSession *coresecrets.VaultSession fileWatcher *filewatcher.Service debug bool activityEvents map[string]bool @@ -712,6 +714,7 @@ func (a *App) CreateVault(path string) error { if err := a.vault.CreateVault(path); err != nil { return err } + a.secretsSession = nil a.startFileWatcherForOpenVault() return nil } @@ -724,6 +727,7 @@ func (a *App) OpenVault(path string) error { if err := a.vault.OpenVault(path); err != nil { return err } + a.secretsSession = nil a.startFileWatcherForOpenVault() return nil } @@ -737,6 +741,7 @@ func (a *App) CloseVault() error { a.fileWatcher.Stop() } a.vault.CloseVault() + a.secretsSession = nil return nil } @@ -1277,6 +1282,166 @@ func (a *App) EditWorkbenchResource(pluginID string, rawRequest map[string]inter return a.OpenWorkbenchResource(pluginID, rawRequest) } +// ─── Secrets API ──────────────────────────────────────────── + +func (a *App) requirePluginSecretsAccess(pluginID string, write bool) error { + if _, err := a.requirePluginAccess(pluginID, "secrets.read"); err != nil { + return err + } + if write { + if _, err := a.requirePluginAccess(pluginID, "secrets.write"); err != nil { + return err + } + } + return nil +} + +func (a *App) ensureSecretsSession() (*coresecrets.VaultSession, error) { + if err := a.requireVault(); err != nil { + return nil, err + } + if a.secretsSession == nil { + a.secretsSession = coresecrets.NewVaultSession(filepath.Join(a.vaultPath(), ".verstak", "secrets")) + } + return a.secretsSession, nil +} + +func (a *App) requireUnlockedSecretStore() (*coresecrets.Store, error) { + session, err := a.ensureSecretsSession() + if err != nil { + return nil, err + } + return session.Store() +} + +func (a *App) PluginSecretsStatus(pluginID string) (map[string]interface{}, string) { + if err := a.requirePluginSecretsAccess(pluginID, false); err != nil { + return nil, err.Error() + } + session, err := a.ensureSecretsSession() + if err != nil { + return nil, err.Error() + } + return map[string]interface{}{ + "unlocked": session.Unlocked(), + }, "" +} + +func (a *App) PluginSecretsUnlock(pluginID, masterPassword string) string { + if err := a.requirePluginSecretsAccess(pluginID, false); err != nil { + return err.Error() + } + session, err := a.ensureSecretsSession() + if err != nil { + return err.Error() + } + if _, err := session.Unlock(masterPassword); err != nil { + return err.Error() + } + return "" +} + +func (a *App) PluginSecretsList(pluginID string) ([]map[string]interface{}, string) { + if err := a.requirePluginSecretsAccess(pluginID, false); err != nil { + return nil, err.Error() + } + store, err := a.requireUnlockedSecretStore() + if err != nil { + return nil, err.Error() + } + records, err := store.ListRecords() + if err != nil { + return nil, err.Error() + } + result := make([]map[string]interface{}, 0, len(records)) + for _, record := range records { + result = append(result, secretRecordMap(record, false)) + } + return result, "" +} + +func (a *App) PluginSecretsRead(pluginID, secretID string) (map[string]interface{}, string) { + if err := a.requirePluginSecretsAccess(pluginID, false); err != nil { + return nil, err.Error() + } + store, err := a.requireUnlockedSecretStore() + if err != nil { + return nil, err.Error() + } + record, err := store.ReadRecord(secretID) + if err != nil { + return nil, err.Error() + } + return secretRecordMap(record, true), "" +} + +func (a *App) PluginSecretsWrite(pluginID string, rawRecord map[string]interface{}) (map[string]interface{}, string) { + if err := a.requirePluginSecretsAccess(pluginID, true); err != nil { + return nil, err.Error() + } + store, err := a.requireUnlockedSecretStore() + if err != nil { + return nil, err.Error() + } + record, err := decodeSecretRecord(rawRecord) + if err != nil { + return nil, err.Error() + } + if err := store.WriteRecord(record); err != nil { + return nil, err.Error() + } + written, err := store.ReadRecord(record.ID) + if err != nil { + return nil, err.Error() + } + return secretRecordMap(written, false), "" +} + +func (a *App) PluginSecretsCopyLink(pluginID, secretID string) (string, string) { + if err := a.requirePluginSecretsAccess(pluginID, false); err != nil { + return "", err.Error() + } + store, err := a.requireUnlockedSecretStore() + if err != nil { + return "", err.Error() + } + record, err := store.ReadRecord(secretID) + if err != nil { + return "", err.Error() + } + title := strings.TrimSpace(record.Title) + if title == "" { + title = record.ID + } + return fmt.Sprintf("[%s](verstak-secret://%s)", title, url.PathEscape(record.ID)), "" +} + +func decodeSecretRecord(raw map[string]interface{}) (coresecrets.SecretRecord, error) { + data, err := json.Marshal(raw) + if err != nil { + return coresecrets.SecretRecord{}, err + } + var record coresecrets.SecretRecord + if err := json.Unmarshal(data, &record); err != nil { + return coresecrets.SecretRecord{}, err + } + return record, nil +} + +func secretRecordMap(record coresecrets.SecretRecord, includeValue bool) map[string]interface{} { + result := map[string]interface{}{ + "id": record.ID, + "title": record.Title, + "scope": map[string]interface{}{"kind": record.Scope.Kind, "workspaceRootPath": record.Scope.WorkspaceRootPath}, + "username": record.Username, + "updatedAt": record.UpdatedAt, + } + if includeValue { + result["value"] = record.Value + } + return result +} + func (a *App) GetWorkbenchOpenedResources() []coreworkbench.OpenedResource { return a.ensureWorkbench().OpenedResources() } @@ -1448,6 +1613,7 @@ func (a *App) SetCurrentVault(path string) string { if err := a.vault.OpenVault(path); err != nil { return fmt.Sprintf("failed to open vault: %v", err) } + a.secretsSession = nil // Save the actual vault path (normalized by OpenVault, includes VerstakVault/) vaultPath := a.vault.GetVaultPath() if err := a.appSettings.SetCurrentVault(vaultPath); err != nil { diff --git a/internal/api/app_test.go b/internal/api/app_test.go index 0caddc4..785272c 100644 --- a/internal/api/app_test.go +++ b/internal/api/app_test.go @@ -1432,6 +1432,17 @@ func newBridgeTestApp(t *testing.T) *App { Status: plugin.StatusDisabled, Enabled: false, }, + { + Manifest: plugin.Manifest{ + ID: "secrets.plugin", + Name: "Secrets Plugin", + Version: "1.0.0", + Provides: []string{"secret-store", "secrets.read-ui", "secrets.write-ui"}, + Permissions: []string{"secrets.read", "secrets.write", "workbench.open"}, + }, + Status: plugin.StatusLoaded, + Enabled: true, + }, }, } } @@ -1607,6 +1618,100 @@ func TestWorkbenchDisabledPluginProviderExcluded(t *testing.T) { } } +func TestPluginSecretsRequirePermissionsAndUnlock(t *testing.T) { + app := newBridgeTestApp(t) + + status, errStr := app.PluginSecretsStatus("secrets.plugin") + if errStr != "" { + t.Fatalf("PluginSecretsStatus: %s", errStr) + } + if status["unlocked"] == true { + t.Fatalf("new secret session should be locked: %+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) + } + if _, errStr := app.PluginSecretsList("secrets.plugin"); !strings.Contains(errStr, "locked") { + t.Fatalf("PluginSecretsList before unlock err = %q, want locked", errStr) + } + + if errStr := app.PluginSecretsUnlock("secrets.plugin", "master password"); errStr != "" { + t.Fatalf("PluginSecretsUnlock: %s", errStr) + } + status, errStr = app.PluginSecretsStatus("secrets.plugin") + if errStr != "" { + t.Fatalf("PluginSecretsStatus after unlock: %s", errStr) + } + if status["unlocked"] != true { + t.Fatalf("secret session not unlocked: %+v", status) + } + + writeResult, errStr := app.PluginSecretsWrite("secrets.plugin", map[string]interface{}{ + "id": "client-a.database", + "title": "Client A Database", + "value": "workspace-secret-value", + "username": "app", + "scope": map[string]interface{}{ + "kind": "workspace", + "workspaceRootPath": "ClientA", + }, + }) + if errStr != "" { + t.Fatalf("PluginSecretsWrite: %s", errStr) + } + if writeResult["id"] != "client-a.database" || writeResult["value"] != nil { + t.Fatalf("write result = %+v", writeResult) + } + + list, errStr := app.PluginSecretsList("secrets.plugin") + if errStr != "" { + t.Fatalf("PluginSecretsList: %s", errStr) + } + if len(list) != 1 { + t.Fatalf("PluginSecretsList len = %d, want 1: %+v", len(list), list) + } + if list[0]["value"] != nil { + t.Fatalf("PluginSecretsList leaked value: %+v", list[0]) + } + scope, _ := list[0]["scope"].(map[string]interface{}) + if scope["kind"] != "workspace" || scope["workspaceRootPath"] != "ClientA" { + t.Fatalf("list scope = %+v", scope) + } + + readResult, errStr := app.PluginSecretsRead("secrets.plugin", "client-a.database") + if errStr != "" { + t.Fatalf("PluginSecretsRead: %s", errStr) + } + if readResult["value"] != "workspace-secret-value" { + t.Fatalf("read result = %+v", readResult) + } + + link, errStr := app.PluginSecretsCopyLink("secrets.plugin", "client-a.database") + if errStr != "" { + t.Fatalf("PluginSecretsCopyLink: %s", errStr) + } + if link != "[Client A Database](verstak-secret://client-a.database)" { + t.Fatalf("link = %q", link) + } +} + +func TestPluginSecretsRejectWrongMasterPasswordAcrossSessions(t *testing.T) { + app := newBridgeTestApp(t) + if errStr := app.PluginSecretsUnlock("secrets.plugin", "master password"); errStr != "" { + t.Fatalf("PluginSecretsUnlock first: %s", errStr) + } + + next := newBridgeTestApp(t) + next.vault = app.vault + if errStr := next.PluginSecretsUnlock("secrets.plugin", "wrong password"); !strings.Contains(errStr, "invalid master password") { + t.Fatalf("wrong unlock err = %q, want invalid master password", errStr) + } + if errStr := next.PluginSecretsUnlock("secrets.plugin", "master password"); errStr != "" { + t.Fatalf("PluginSecretsUnlock correct: %s", errStr) + } +} + func TestPluginBridgeSettingsRequireLoadedPluginAndStoragePermission(t *testing.T) { app := newBridgeTestApp(t) diff --git a/internal/core/secrets/store.go b/internal/core/secrets/store.go index e895922..840d61a 100644 --- a/internal/core/secrets/store.go +++ b/internal/core/secrets/store.go @@ -4,6 +4,7 @@ package secrets import ( "crypto/aes" "crypto/cipher" + "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/hex" @@ -18,8 +19,16 @@ import ( ) const ( - keySize = 32 - nonceSize = 12 + keySize = 32 + nonceSize = 12 + masterSaltSize = 16 + masterPBKDF2Iterations = 200000 + masterVerifierPlaintext = "verstak-secret-store:v1" + masterMetadataVersion = 1 + recordsDirName = "records" + masterMetadataFileName = "metadata.json" + ScopeGlobal = "global" + ScopeWorkspace = "workspace" ) // Store encrypts secret records before writing them to disk. @@ -29,6 +38,20 @@ type Store struct { key []byte } +type SecretScope struct { + Kind string `json:"kind"` + WorkspaceRootPath string `json:"workspaceRootPath,omitempty"` +} + +type SecretRecord struct { + ID string `json:"id"` + Title string `json:"title"` + Value string `json:"value,omitempty"` + Scope SecretScope `json:"scope"` + Username string `json:"username,omitempty"` + UpdatedAt string `json:"updatedAt"` +} + type encryptedRecord struct { Version int `json:"version"` Nonce []byte `json:"nonce"` @@ -37,8 +60,26 @@ type encryptedRecord struct { } type plaintextRecord struct { - ID string `json:"id"` - Value string `json:"value"` + ID string `json:"id"` + Title string `json:"title,omitempty"` + Value string `json:"value"` + Scope SecretScope `json:"scope"` + Username string `json:"username,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` +} + +type masterMetadata struct { + Version int `json:"version"` + Salt []byte `json:"salt"` + Nonce []byte `json:"nonce"` + Ciphertext []byte `json:"ciphertext"` + CreatedAt string `json:"createdAt"` +} + +type VaultSession struct { + mu sync.RWMutex + root string + store *Store } // NewStore creates an encrypted secret store rooted at root. @@ -60,11 +101,32 @@ func NewStore(root string, key []byte) (*Store, error) { // Write encrypts and stores a secret value by ID. func (s *Store) Write(id, value string) error { - if err := validateID(id); err != nil { + return s.WriteRecord(SecretRecord{ + ID: id, + Title: id, + Value: value, + Scope: SecretScope{Kind: ScopeGlobal}, + }) +} + +func (s *Store) WriteRecord(record SecretRecord) error { + record.ID = strings.TrimSpace(record.ID) + record.Title = strings.TrimSpace(record.Title) + record.Scope.Kind = strings.TrimSpace(record.Scope.Kind) + record.Scope.WorkspaceRootPath = cleanWorkspaceRoot(record.Scope.WorkspaceRootPath) + if err := validateRecord(record); err != nil { return err } + record.UpdatedAt = time.Now().UTC().Format(time.RFC3339) - plaintext, err := json.Marshal(plaintextRecord{ID: id, Value: value}) + plaintext, err := json.Marshal(plaintextRecord{ + ID: record.ID, + Title: record.Title, + Value: record.Value, + Scope: record.Scope, + Username: record.Username, + UpdatedAt: record.UpdatedAt, + }) if err != nil { return fmt.Errorf("marshal secret: %w", err) } @@ -78,60 +140,113 @@ func (s *Store) Write(id, value string) error { if err != nil { return err } - record := encryptedRecord{ + encrypted := encryptedRecord{ Version: 1, Nonce: nonce, - Ciphertext: aead.Seal(nil, nonce, plaintext, []byte(id)), + Ciphertext: aead.Seal(nil, nonce, plaintext, nil), UpdatedAt: time.Now().UTC().Format(time.RFC3339), } - data, err := json.MarshalIndent(record, "", " ") + data, err := json.MarshalIndent(encrypted, "", " ") if err != nil { return fmt.Errorf("marshal encrypted secret: %w", err) } s.mu.Lock() defer s.mu.Unlock() - return atomicWrite0600(s.pathForID(id), data) + return atomicWrite0600(s.pathForID(record.ID), data) } // Read decrypts and returns a secret value by ID. func (s *Store) Read(id string) (string, error) { - if err := validateID(id); err != nil { + record, err := s.ReadRecord(id) + if err != nil { return "", err } + return record.Value, nil +} + +func (s *Store) ReadRecord(id string) (SecretRecord, error) { + if err := validateID(id); err != nil { + return SecretRecord{}, err + } s.mu.RLock() data, err := os.ReadFile(s.pathForID(id)) s.mu.RUnlock() if err != nil { - return "", fmt.Errorf("read secret %q: %w", id, err) + return SecretRecord{}, fmt.Errorf("read secret %q: %w", id, err) } + decoded, err := s.decryptRecord(id, data) + if err != nil { + return SecretRecord{}, err + } + return decoded, nil +} + +func (s *Store) ListRecords() ([]SecretRecord, error) { + s.mu.RLock() + entries, err := os.ReadDir(s.root) + s.mu.RUnlock() + if err != nil { + if os.IsNotExist(err) { + return []SecretRecord{}, nil + } + return nil, fmt.Errorf("list secrets: %w", err) + } + + records := make([]SecretRecord, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { + continue + } + path := filepath.Join(s.root, entry.Name()) + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read secret list item %s: %w", entry.Name(), err) + } + record, err := s.decryptRecord("", data) + if err != nil { + return nil, fmt.Errorf("decrypt secret list item %s: %w", entry.Name(), err) + } + record.Value = "" + records = append(records, record) + } + return records, nil +} + +func (s *Store) decryptRecord(expectedID string, data []byte) (SecretRecord, error) { var record encryptedRecord if err := json.Unmarshal(data, &record); err != nil { - return "", fmt.Errorf("decode encrypted secret %q: %w", id, err) + return SecretRecord{}, fmt.Errorf("decode encrypted secret %q: %w", expectedID, err) } if record.Version != 1 { - return "", fmt.Errorf("unsupported secret version %d", record.Version) + return SecretRecord{}, fmt.Errorf("unsupported secret version %d", record.Version) } - aead, err := s.aead() if err != nil { - return "", err + return SecretRecord{}, err } - plaintext, err := aead.Open(nil, record.Nonce, record.Ciphertext, []byte(id)) + plaintext, err := aead.Open(nil, record.Nonce, record.Ciphertext, nil) if err != nil { - return "", fmt.Errorf("decrypt secret %q: %w", id, err) + return SecretRecord{}, fmt.Errorf("decrypt secret %q: %w", expectedID, err) } var decoded plaintextRecord if err := json.Unmarshal(plaintext, &decoded); err != nil { - return "", fmt.Errorf("decode secret %q: %w", id, err) + return SecretRecord{}, fmt.Errorf("decode secret %q: %w", expectedID, err) } - if decoded.ID != id { - return "", fmt.Errorf("secret %q contains mismatched id", id) + if expectedID != "" && decoded.ID != expectedID { + return SecretRecord{}, fmt.Errorf("secret %q contains mismatched id", expectedID) } - return decoded.Value, nil + return SecretRecord{ + ID: decoded.ID, + Title: decoded.Title, + Value: decoded.Value, + Scope: decoded.Scope, + Username: decoded.Username, + UpdatedAt: decoded.UpdatedAt, + }, nil } func (s *Store) aead() (cipher.AEAD, error) { @@ -151,6 +266,35 @@ func (s *Store) pathForID(id string) string { return filepath.Join(s.root, hex.EncodeToString(sum[:])+".json") } +func validateRecord(record SecretRecord) error { + if err := validateID(record.ID); err != nil { + return err + } + if record.Title == "" { + return fmt.Errorf("secret title is empty") + } + switch record.Scope.Kind { + case ScopeGlobal: + if record.Scope.WorkspaceRootPath != "" { + return fmt.Errorf("global secret must not have workspace root path") + } + case ScopeWorkspace: + if record.Scope.WorkspaceRootPath == "" { + return fmt.Errorf("workspace secret requires workspace root path") + } + if strings.ContainsAny(record.Scope.WorkspaceRootPath, `\`) { + return fmt.Errorf("workspace root path contains path separators") + } + default: + return fmt.Errorf("unsupported secret scope %q", record.Scope.Kind) + } + return nil +} + +func cleanWorkspaceRoot(value string) string { + return strings.Trim(strings.TrimSpace(value), "/") +} + func validateID(id string) error { if id == "" { return fmt.Errorf("secret id is empty") @@ -186,3 +330,172 @@ func atomicWrite0600(path string, data []byte) error { } return nil } + +func NewVaultSession(root string) *VaultSession { + return &VaultSession{root: root} +} + +func (s *VaultSession) Unlocked() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.store != nil +} + +func (s *VaultSession) Store() (*Store, error) { + s.mu.RLock() + defer s.mu.RUnlock() + if s.store == nil { + return nil, fmt.Errorf("secret store locked") + } + return s.store, nil +} + +func (s *VaultSession) Unlock(masterPassword string) (*Store, error) { + if strings.TrimSpace(masterPassword) == "" { + return nil, fmt.Errorf("master password is empty") + } + + s.mu.Lock() + defer s.mu.Unlock() + if s.store != nil { + return s.store, nil + } + + metadata, err := readMasterMetadata(s.root) + if err != nil { + return nil, err + } + if metadata == nil { + created, key, err := createMasterMetadata(masterPassword) + if err != nil { + return nil, err + } + if err := writeMasterMetadata(s.root, *created); err != nil { + return nil, err + } + store, err := NewStore(filepath.Join(s.root, recordsDirName), key) + if err != nil { + return nil, err + } + s.store = store + return store, nil + } + + key := deriveMasterKey(masterPassword, metadata.Salt) + if err := verifyMasterMetadata(*metadata, key); err != nil { + return nil, err + } + store, err := NewStore(filepath.Join(s.root, recordsDirName), key) + if err != nil { + return nil, err + } + s.store = store + return store, nil +} + +func readMasterMetadata(root string) (*masterMetadata, error) { + data, err := os.ReadFile(filepath.Join(root, masterMetadataFileName)) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("read secret metadata: %w", err) + } + var metadata masterMetadata + if err := json.Unmarshal(data, &metadata); err != nil { + return nil, fmt.Errorf("decode secret metadata: %w", err) + } + if metadata.Version != masterMetadataVersion { + return nil, fmt.Errorf("unsupported secret metadata version %d", metadata.Version) + } + return &metadata, nil +} + +func createMasterMetadata(masterPassword string) (*masterMetadata, []byte, error) { + salt := make([]byte, masterSaltSize) + if _, err := io.ReadFull(rand.Reader, salt); err != nil { + return nil, nil, fmt.Errorf("generate secret salt: %w", err) + } + key := deriveMasterKey(masterPassword, salt) + nonce := make([]byte, nonceSize) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, nil, fmt.Errorf("generate verifier nonce: %w", err) + } + aead, err := newAEAD(key) + if err != nil { + return nil, nil, err + } + return &masterMetadata{ + Version: masterMetadataVersion, + Salt: salt, + Nonce: nonce, + Ciphertext: aead.Seal(nil, nonce, []byte(masterVerifierPlaintext), nil), + CreatedAt: time.Now().UTC().Format(time.RFC3339), + }, key, nil +} + +func verifyMasterMetadata(metadata masterMetadata, key []byte) error { + aead, err := newAEAD(key) + if err != nil { + return err + } + plaintext, err := aead.Open(nil, metadata.Nonce, metadata.Ciphertext, nil) + if err != nil { + return fmt.Errorf("invalid master password") + } + if !hmac.Equal(plaintext, []byte(masterVerifierPlaintext)) { + return fmt.Errorf("invalid master password") + } + return nil +} + +func writeMasterMetadata(root string, metadata masterMetadata) error { + data, err := json.MarshalIndent(metadata, "", " ") + if err != nil { + return fmt.Errorf("marshal secret metadata: %w", err) + } + return atomicWrite0600(filepath.Join(root, masterMetadataFileName), data) +} + +func deriveMasterKey(masterPassword string, salt []byte) []byte { + return pbkdf2SHA256([]byte(masterPassword), salt, masterPBKDF2Iterations, keySize) +} + +func newAEAD(key []byte) (cipher.AEAD, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("create secret cipher: %w", err) + } + aead, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("create secret gcm: %w", err) + } + return aead, nil +} + +func pbkdf2SHA256(password, salt []byte, iterations, size int) []byte { + if iterations <= 0 || size <= 0 { + return nil + } + hashLen := sha256.Size + blocks := (size + hashLen - 1) / hashLen + output := make([]byte, 0, blocks*hashLen) + for block := 1; block <= blocks; block++ { + mac := hmac.New(sha256.New, password) + mac.Write(salt) + mac.Write([]byte{byte(block >> 24), byte(block >> 16), byte(block >> 8), byte(block)}) + u := mac.Sum(nil) + t := make([]byte, len(u)) + copy(t, u) + for i := 1; i < iterations; i++ { + mac = hmac.New(sha256.New, password) + mac.Write(u) + u = mac.Sum(nil) + for j := range t { + t[j] ^= u[j] + } + } + output = append(output, t...) + } + return output[:size] +} diff --git a/internal/core/secrets/store_test.go b/internal/core/secrets/store_test.go index ab62ca6..cf53773 100644 --- a/internal/core/secrets/store_test.go +++ b/internal/core/secrets/store_test.go @@ -105,3 +105,122 @@ func TestStoreRejectsUnsafeIDs(t *testing.T) { }) } } + +func TestStoreListsScopedRecordsWithoutPlaintextOnDisk(t *testing.T) { + root := t.TempDir() + store, err := NewStore(root, testKey(0x11)) + if err != nil { + t.Fatalf("NewStore: %v", err) + } + + records := []SecretRecord{ + { + ID: "global.server-token", + Title: "Server Token", + Value: "global-secret-value", + Scope: SecretScope{Kind: ScopeGlobal}, + Username: "root", + UpdatedAt: "caller value must be replaced", + }, + { + ID: "client-a.database", + Title: "Client A Database", + Value: "workspace-secret-value", + Scope: SecretScope{ + Kind: ScopeWorkspace, + WorkspaceRootPath: "ClientA", + }, + Username: "app", + }, + } + for _, record := range records { + if err := store.WriteRecord(record); err != nil { + t.Fatalf("WriteRecord(%s): %v", record.ID, err) + } + } + + list, err := store.ListRecords() + if err != nil { + t.Fatalf("ListRecords: %v", err) + } + if len(list) != 2 { + t.Fatalf("ListRecords len = %d, want 2: %+v", len(list), list) + } + if list[0].Value != "" || list[1].Value != "" { + t.Fatalf("ListRecords leaked secret values: %+v", list) + } + + workspaceRecord, err := store.ReadRecord("client-a.database") + if err != nil { + t.Fatalf("ReadRecord: %v", err) + } + if workspaceRecord.Value != "workspace-secret-value" { + t.Fatalf("workspace secret value = %q", workspaceRecord.Value) + } + if workspaceRecord.Scope.Kind != ScopeWorkspace || workspaceRecord.Scope.WorkspaceRootPath != "ClientA" { + t.Fatalf("workspace scope = %+v", workspaceRecord.Scope) + } + if workspaceRecord.UpdatedAt == "" || workspaceRecord.UpdatedAt == "caller value must be replaced" { + t.Fatalf("UpdatedAt was not set by store: %+v", workspaceRecord) + } + + raw := readAllSecretStoreBytes(t, root) + for _, plaintext := range []string{ + "global-secret-value", + "workspace-secret-value", + "global.server-token", + "client-a.database", + "Client A Database", + "ClientA", + } { + if bytes.Contains(raw, []byte(plaintext)) { + t.Fatalf("secret store contains plaintext %q", plaintext) + } + } +} + +func TestVaultSessionUnlocksWithMasterPasswordOnce(t *testing.T) { + root := t.TempDir() + session := NewVaultSession(root) + + if session.Unlocked() { + t.Fatal("new session is unlocked") + } + if _, err := session.Store(); err == nil { + t.Fatal("Store before unlock succeeded") + } + + store, err := session.Unlock("correct horse battery staple") + if err != nil { + t.Fatalf("Unlock first time: %v", err) + } + if !session.Unlocked() { + t.Fatal("session did not stay unlocked") + } + if err := store.Write("server.password", "s3cr3t-value"); err != nil { + t.Fatalf("Write: %v", err) + } + + sameStore, err := session.Store() + if err != nil { + t.Fatalf("Store after unlock: %v", err) + } + if got, err := sameStore.Read("server.password"); err != nil || got != "s3cr3t-value" { + t.Fatalf("Read after unlock = %q, %v", got, err) + } + + nextSession := NewVaultSession(root) + if _, err := nextSession.Unlock("wrong password"); err == nil { + t.Fatal("Unlock with wrong password succeeded") + } + if _, err := nextSession.Unlock("correct horse battery staple"); err != nil { + t.Fatalf("Unlock with correct password: %v", err) + } + + raw := readAllSecretStoreBytes(t, root) + for _, plaintext := range []string{"correct horse battery staple", "s3cr3t-value"} { + if bytes.Contains(raw, []byte(plaintext)) { + t.Fatalf("vault session storage contains plaintext %q", plaintext) + } + } +}