feat: expose scoped secret store API
This commit is contained in:
parent
7a7b3c7a3e
commit
56c685212b
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 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 ReadPluginDataJSON(arg1:string,arg2:string):Promise<Record<string, any>>;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue