feat: expose scoped secret store API

This commit is contained in:
mirivlad 2026-06-29 20:36:45 +08:00
parent 7a7b3c7a3e
commit 56c685212b
7 changed files with 808 additions and 22 deletions

View File

@ -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: { files: {
list: function(relativeDir) { list: function(relativeDir) {
assertActive('files.list'); assertActive('files.list');

View File

@ -94,6 +94,18 @@ export function PluginSyncStatus(arg1:string):Promise<api.SyncStatusDTO|string>;
export function PluginSyncTestConnection(arg1:string,arg2:string,arg3:string,arg4:string):Promise<string>; export function PluginSyncTestConnection(arg1:string,arg2:string,arg3:string,arg4:string):Promise<string>;
export function PluginSecretsCopyLink(arg1:string,arg2:string):Promise<string|string>;
export function PluginSecretsList(arg1:string):Promise<Array<Record<string, any>>|string>;
export function PluginSecretsRead(arg1:string,arg2:string):Promise<Record<string, any>|string>;
export function PluginSecretsStatus(arg1:string):Promise<Record<string, any>|string>;
export function PluginSecretsUnlock(arg1:string,arg2:string):Promise<string>;
export function PluginSecretsWrite(arg1:string,arg2:Record<string, any>):Promise<Record<string, any>|string>;
export function PublishPluginEvent(arg1:string,arg2:string,arg3:Record<string, any>):Promise<string>; export function PublishPluginEvent(arg1:string,arg2:string,arg3:Record<string, any>):Promise<string>;
export function ReadPluginDataJSON(arg1:string,arg2:string):Promise<Record<string, any>>; export function ReadPluginDataJSON(arg1:string,arg2:string):Promise<Record<string, any>>;

View File

@ -174,6 +174,30 @@ export function PluginSyncTestConnection(arg1, arg2, arg3, arg4) {
return window['go']['api']['App']['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) { export function PublishPluginEvent(arg1, arg2, arg3) {
return window['go']['api']['App']['PublishPluginEvent'](arg1, arg2, arg3); return window['go']['api']['App']['PublishPluginEvent'](arg1, arg2, arg3);
} }

View File

@ -24,6 +24,7 @@ import (
"github.com/verstak/verstak-desktop/internal/core/permissions" "github.com/verstak/verstak-desktop/internal/core/permissions"
"github.com/verstak/verstak-desktop/internal/core/plugin" "github.com/verstak/verstak-desktop/internal/core/plugin"
"github.com/verstak/verstak-desktop/internal/core/pluginstate" "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" "github.com/verstak/verstak-desktop/internal/core/storage"
syncsvc "github.com/verstak/verstak-desktop/internal/core/sync" syncsvc "github.com/verstak/verstak-desktop/internal/core/sync"
"github.com/verstak/verstak-desktop/internal/core/vault" "github.com/verstak/verstak-desktop/internal/core/vault"
@ -61,6 +62,7 @@ type App struct {
workbench *coreworkbench.Router workbench *coreworkbench.Router
workspace *workspace.Manager workspace *workspace.Manager
syncSvc *syncsvc.Service syncSvc *syncsvc.Service
secretsSession *coresecrets.VaultSession
fileWatcher *filewatcher.Service fileWatcher *filewatcher.Service
debug bool debug bool
activityEvents map[string]bool activityEvents map[string]bool
@ -712,6 +714,7 @@ func (a *App) CreateVault(path string) error {
if err := a.vault.CreateVault(path); err != nil { if err := a.vault.CreateVault(path); err != nil {
return err return err
} }
a.secretsSession = nil
a.startFileWatcherForOpenVault() a.startFileWatcherForOpenVault()
return nil return nil
} }
@ -724,6 +727,7 @@ func (a *App) OpenVault(path string) error {
if err := a.vault.OpenVault(path); err != nil { if err := a.vault.OpenVault(path); err != nil {
return err return err
} }
a.secretsSession = nil
a.startFileWatcherForOpenVault() a.startFileWatcherForOpenVault()
return nil return nil
} }
@ -737,6 +741,7 @@ func (a *App) CloseVault() error {
a.fileWatcher.Stop() a.fileWatcher.Stop()
} }
a.vault.CloseVault() a.vault.CloseVault()
a.secretsSession = nil
return nil return nil
} }
@ -1277,6 +1282,166 @@ func (a *App) EditWorkbenchResource(pluginID string, rawRequest map[string]inter
return a.OpenWorkbenchResource(pluginID, rawRequest) 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 { func (a *App) GetWorkbenchOpenedResources() []coreworkbench.OpenedResource {
return a.ensureWorkbench().OpenedResources() return a.ensureWorkbench().OpenedResources()
} }
@ -1448,6 +1613,7 @@ func (a *App) SetCurrentVault(path string) string {
if err := a.vault.OpenVault(path); err != nil { if err := a.vault.OpenVault(path); err != nil {
return fmt.Sprintf("failed to open vault: %v", err) return fmt.Sprintf("failed to open vault: %v", err)
} }
a.secretsSession = nil
// Save the actual vault path (normalized by OpenVault, includes VerstakVault/) // Save the actual vault path (normalized by OpenVault, includes VerstakVault/)
vaultPath := a.vault.GetVaultPath() vaultPath := a.vault.GetVaultPath()
if err := a.appSettings.SetCurrentVault(vaultPath); err != nil { if err := a.appSettings.SetCurrentVault(vaultPath); err != nil {

View File

@ -1432,6 +1432,17 @@ func newBridgeTestApp(t *testing.T) *App {
Status: plugin.StatusDisabled, Status: plugin.StatusDisabled,
Enabled: false, 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) { func TestPluginBridgeSettingsRequireLoadedPluginAndStoragePermission(t *testing.T) {
app := newBridgeTestApp(t) app := newBridgeTestApp(t)

View File

@ -4,6 +4,7 @@ package secrets
import ( import (
"crypto/aes" "crypto/aes"
"crypto/cipher" "crypto/cipher"
"crypto/hmac"
"crypto/rand" "crypto/rand"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
@ -20,6 +21,14 @@ import (
const ( const (
keySize = 32 keySize = 32
nonceSize = 12 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. // Store encrypts secret records before writing them to disk.
@ -29,6 +38,20 @@ type Store struct {
key []byte 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 { type encryptedRecord struct {
Version int `json:"version"` Version int `json:"version"`
Nonce []byte `json:"nonce"` Nonce []byte `json:"nonce"`
@ -38,7 +61,25 @@ type encryptedRecord struct {
type plaintextRecord struct { type plaintextRecord struct {
ID string `json:"id"` ID string `json:"id"`
Title string `json:"title,omitempty"`
Value string `json:"value"` 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. // 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. // Write encrypts and stores a secret value by ID.
func (s *Store) Write(id, value string) error { 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 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 { if err != nil {
return fmt.Errorf("marshal secret: %w", err) return fmt.Errorf("marshal secret: %w", err)
} }
@ -78,60 +140,113 @@ func (s *Store) Write(id, value string) error {
if err != nil { if err != nil {
return err return err
} }
record := encryptedRecord{ encrypted := encryptedRecord{
Version: 1, Version: 1,
Nonce: nonce, Nonce: nonce,
Ciphertext: aead.Seal(nil, nonce, plaintext, []byte(id)), Ciphertext: aead.Seal(nil, nonce, plaintext, nil),
UpdatedAt: time.Now().UTC().Format(time.RFC3339), UpdatedAt: time.Now().UTC().Format(time.RFC3339),
} }
data, err := json.MarshalIndent(record, "", " ") data, err := json.MarshalIndent(encrypted, "", " ")
if err != nil { if err != nil {
return fmt.Errorf("marshal encrypted secret: %w", err) return fmt.Errorf("marshal encrypted secret: %w", err)
} }
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() 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. // Read decrypts and returns a secret value by ID.
func (s *Store) Read(id string) (string, error) { 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 "", 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() s.mu.RLock()
data, err := os.ReadFile(s.pathForID(id)) data, err := os.ReadFile(s.pathForID(id))
s.mu.RUnlock() s.mu.RUnlock()
if err != nil { 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 var record encryptedRecord
if err := json.Unmarshal(data, &record); err != nil { 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 { 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() aead, err := s.aead()
if err != nil { 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 { 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 var decoded plaintextRecord
if err := json.Unmarshal(plaintext, &decoded); err != nil { 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 { if expectedID != "" && decoded.ID != expectedID {
return "", fmt.Errorf("secret %q contains mismatched id", id) 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) { 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") 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 { func validateID(id string) error {
if id == "" { if id == "" {
return fmt.Errorf("secret id is empty") return fmt.Errorf("secret id is empty")
@ -186,3 +330,172 @@ func atomicWrite0600(path string, data []byte) error {
} }
return nil 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]
}

View File

@ -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)
}
}
}