From e610ced77ea6f3ff77bd003bb2f51bd9849f6db9 Mon Sep 17 00:00:00 2001 From: sawka Date: Wed, 5 Nov 2025 12:27:46 -0800 Subject: [PATCH 1/4] rudimentary secret store for wave --- emain/emain-wsh.ts | 38 ++++- frontend/app/store/wshclientapi.ts | 25 ++++ frontend/types/gotypes.d.ts | 21 +++ pkg/secretstore/secretstore.go | 213 +++++++++++++++++++++++++++++ pkg/wshrpc/wshclient/wshclient.go | 30 ++++ pkg/wshrpc/wshrpctypes.go | 33 +++++ pkg/wshrpc/wshserver/wshserver.go | 33 +++++ 7 files changed, 392 insertions(+), 1 deletion(-) create mode 100644 pkg/secretstore/secretstore.go diff --git a/emain/emain-wsh.ts b/emain/emain-wsh.ts index aaf330038a..5452ad76fb 100644 --- a/emain/emain-wsh.ts +++ b/emain/emain-wsh.ts @@ -4,7 +4,7 @@ import { WindowService } from "@/app/store/services"; import { RpcResponseHelper, WshClient } from "@/app/store/wshclient"; import { RpcApi } from "@/app/store/wshclientapi"; -import { Notification } from "electron"; +import { Notification, safeStorage } from "electron"; import { getResolvedUpdateChannel } from "emain/updater"; import { unamePlatform } from "./emain-platform"; import { getWebContentsByBlockId, webGetSelector } from "./emain-web"; @@ -60,6 +60,42 @@ export class ElectronWshClientType extends WshClient { ww.focus(); } + async handle_electronencrypt( + rh: RpcResponseHelper, + data: CommandElectronEncryptData + ): Promise { + if (!safeStorage.isEncryptionAvailable()) { + throw new Error("encryption is not available"); + } + const encrypted = safeStorage.encryptString(data.plaintext); + const ciphertext = encrypted.toString("base64"); + + let storagebackend = ""; + if (process.platform === "linux") { + storagebackend = safeStorage.getSelectedStorageBackend(); + } + + return { + ciphertext, + storagebackend, + }; + } + + async handle_electrondecrypt( + rh: RpcResponseHelper, + data: CommandElectronDecryptData + ): Promise { + if (!safeStorage.isEncryptionAvailable()) { + throw new Error("encryption is not available"); + } + const encrypted = Buffer.from(data.ciphertext, "base64"); + const plaintext = safeStorage.decryptString(encrypted); + + return { + plaintext, + }; + } + // async handle_workspaceupdate(rh: RpcResponseHelper) { // console.log("workspaceupdate"); // fireAndForget(async () => { diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index e1e71f3bca..4bbcdced36 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -147,6 +147,16 @@ class RpcApiType { return client.wshRpcCall("disposesuggestions", data, opts); } + // command "electrondecrypt" [call] + ElectronDecryptCommand(client: WshClient, data: CommandElectronDecryptData, opts?: RpcOpts): Promise { + return client.wshRpcCall("electrondecrypt", data, opts); + } + + // command "electronencrypt" [call] + ElectronEncryptCommand(client: WshClient, data: CommandElectronEncryptData, opts?: RpcOpts): Promise { + return client.wshRpcCall("electronencrypt", data, opts); + } + // command "eventpublish" [call] EventPublishCommand(client: WshClient, data: WaveEvent, opts?: RpcOpts): Promise { return client.wshRpcCall("eventpublish", data, opts); @@ -297,6 +307,16 @@ class RpcApiType { return client.wshRpcCall("getrtinfo", data, opts); } + // command "getsecrets" [call] + GetSecretsCommand(client: WshClient, data: string[], opts?: RpcOpts): Promise<{[key: string]: string}> { + return client.wshRpcCall("getsecrets", data, opts); + } + + // command "getsecretsnames" [call] + GetSecretsNamesCommand(client: WshClient, opts?: RpcOpts): Promise { + return client.wshRpcCall("getsecretsnames", null, opts); + } + // command "gettab" [call] GetTabCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { return client.wshRpcCall("gettab", data, opts); @@ -472,6 +492,11 @@ class RpcApiType { return client.wshRpcCall("setrtinfo", data, opts); } + // command "setsecrets" [call] + SetSecretsCommand(client: WshClient, data: {[key: string]: string}, opts?: RpcOpts): Promise { + return client.wshRpcCall("setsecrets", data, opts); + } + // command "setvar" [call] SetVarCommand(client: WshClient, data: CommandVarData, opts?: RpcOpts): Promise { return client.wshRpcCall("setvar", data, opts); diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 1de284dee5..31f5700ad9 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -227,6 +227,27 @@ declare global { routeid: string; }; + // wshrpc.CommandElectronDecryptData + type CommandElectronDecryptData = { + ciphertext: string; + }; + + // wshrpc.CommandElectronDecryptRtnData + type CommandElectronDecryptRtnData = { + plaintext: string; + }; + + // wshrpc.CommandElectronEncryptData + type CommandElectronEncryptData = { + plaintext: string; + }; + + // wshrpc.CommandElectronEncryptRtnData + type CommandElectronEncryptRtnData = { + ciphertext: string; + storagebackend: string; + }; + // wshrpc.CommandEventReadHistoryData type CommandEventReadHistoryData = { event: string; diff --git a/pkg/secretstore/secretstore.go b/pkg/secretstore/secretstore.go new file mode 100644 index 0000000000..1f388e6b7b --- /dev/null +++ b/pkg/secretstore/secretstore.go @@ -0,0 +1,213 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package secretstore + +import ( + "context" + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "regexp" + "sync" + "time" + + "github.com/wavetermdev/waveterm/pkg/wavebase" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" + "github.com/wavetermdev/waveterm/pkg/wshutil" +) + +const ( + SecretsFileName = "secrets.enc" + WriteDebounceMs = 1000 + EncryptionTimeout = 5000 + InitRetryMs = 1000 + SecretNamePattern = `^[A-Za-z][A-Za-z0-9_]*$` +) + +var lock sync.Mutex +var secrets = make(map[string]string) +var writeRequestChan chan struct{} +var initialized bool +var lastInitTryTime time.Time +var lastInitErr error +var secretNameRegexp = regexp.MustCompile(SecretNamePattern) + +func readSecretsFromFile() (map[string]string, error) { + configDir := wavebase.GetWaveConfigDir() + secretsPath := filepath.Join(configDir, SecretsFileName) + + encryptedData, err := os.ReadFile(secretsPath) + if err != nil { + if !os.IsNotExist(err) { + log.Printf("secretstore: could not read secrets file: %v\n", err) + } + return make(map[string]string), nil + } + + rpcClient := wshclient.GetBareRpcClient() + ctx, cancel := context.WithTimeout(context.Background(), EncryptionTimeout*time.Millisecond) + defer cancel() + + decryptData := wshrpc.CommandElectronDecryptData{ + CipherText: string(encryptedData), + } + rpcOpts := &wshrpc.RpcOpts{ + Route: wshutil.ElectronRoute, + Timeout: EncryptionTimeout, + } + + result, err := wshclient.ElectronDecryptCommand(rpcClient, decryptData, rpcOpts) + if err != nil { + return nil, fmt.Errorf("failed to decrypt secrets: %w", err) + } + + if ctx.Err() != nil { + return nil, fmt.Errorf("decryption timeout: %w", ctx.Err()) + } + + var decryptedSecrets map[string]string + if err := json.Unmarshal([]byte(result.PlainText), &decryptedSecrets); err != nil { + return nil, fmt.Errorf("failed to parse secrets: %w", err) + } + + return decryptedSecrets, nil +} + +func initSecretStore() error { + lock.Lock() + defer lock.Unlock() + if initialized { + return nil + } + + now := time.Now() + if !lastInitTryTime.IsZero() && now.Sub(lastInitTryTime) < InitRetryMs*time.Millisecond { + return lastInitErr + } + + lastInitTryTime = now + loadedSecrets, err := readSecretsFromFile() + if err != nil { + lastInitErr = err + return err + } + secrets = loadedSecrets + + writeRequestChan = make(chan struct{}, 1) + initialized = true + lastInitErr = nil + go writerLoop() + return nil +} + +func writerLoop() { + var timer *time.Timer + for range writeRequestChan { + if timer != nil { + timer.Stop() + } + timer = time.AfterFunc(WriteDebounceMs*time.Millisecond, func() { + if err := writeSecretsToFile(); err != nil { + log.Printf("secretstore: error writing secrets: %v\n", err) + } + }) + } +} + +func writeSecretsToFile() error { + lock.Lock() + secretsCopy := make(map[string]string, len(secrets)) + for k, v := range secrets { + secretsCopy[k] = v + } + lock.Unlock() + + jsonData, err := json.Marshal(secretsCopy) + if err != nil { + return fmt.Errorf("failed to marshal secrets: %w", err) + } + + rpcClient := wshclient.GetBareRpcClient() + ctx, cancel := context.WithTimeout(context.Background(), EncryptionTimeout*time.Millisecond) + defer cancel() + + encryptData := wshrpc.CommandElectronEncryptData{ + PlainText: string(jsonData), + } + rpcOpts := &wshrpc.RpcOpts{ + Route: wshutil.ElectronRoute, + Timeout: EncryptionTimeout, + } + + result, err := wshclient.ElectronEncryptCommand(rpcClient, encryptData, rpcOpts) + if err != nil { + return fmt.Errorf("failed to encrypt secrets: %w", err) + } + + if ctx.Err() != nil { + return fmt.Errorf("encryption timeout: %w", ctx.Err()) + } + + configDir := wavebase.GetWaveConfigDir() + secretsPath := filepath.Join(configDir, SecretsFileName) + + if err := os.WriteFile(secretsPath, []byte(result.CipherText), 0600); err != nil { + return fmt.Errorf("failed to write secrets file: %w", err) + } + + return nil +} + +func requestWrite() { + select { + case writeRequestChan <- struct{}{}: + default: + } +} + +func SetSecret(name string, value string) error { + if name == "" { + return fmt.Errorf("secret name cannot be empty") + } + if !secretNameRegexp.MatchString(name) { + return fmt.Errorf("secret name must start with a letter and contain only letters, numbers, and underscores") + } + if err := initSecretStore(); err != nil { + return err + } + lock.Lock() + defer lock.Unlock() + + secrets[name] = value + requestWrite() + return nil +} + +func GetSecret(name string) (string, bool, error) { + if err := initSecretStore(); err != nil { + return "", false, err + } + lock.Lock() + defer lock.Unlock() + + value, exists := secrets[name] + return value, exists, nil +} + +func GetSecretNames() ([]string, error) { + if err := initSecretStore(); err != nil { + return nil, err + } + lock.Lock() + defer lock.Unlock() + + names := make([]string, 0, len(secrets)) + for name := range secrets { + names = append(names, name) + } + return names, nil +} diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index 2a6210358c..1dc1f9bf95 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -185,6 +185,18 @@ func DisposeSuggestionsCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcO return err } +// command "electrondecrypt", wshserver.ElectronDecryptCommand +func ElectronDecryptCommand(w *wshutil.WshRpc, data wshrpc.CommandElectronDecryptData, opts *wshrpc.RpcOpts) (*wshrpc.CommandElectronDecryptRtnData, error) { + resp, err := sendRpcRequestCallHelper[*wshrpc.CommandElectronDecryptRtnData](w, "electrondecrypt", data, opts) + return resp, err +} + +// command "electronencrypt", wshserver.ElectronEncryptCommand +func ElectronEncryptCommand(w *wshutil.WshRpc, data wshrpc.CommandElectronEncryptData, opts *wshrpc.RpcOpts) (*wshrpc.CommandElectronEncryptRtnData, error) { + resp, err := sendRpcRequestCallHelper[*wshrpc.CommandElectronEncryptRtnData](w, "electronencrypt", data, opts) + return resp, err +} + // command "eventpublish", wshserver.EventPublishCommand func EventPublishCommand(w *wshutil.WshRpc, data wps.WaveEvent, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "eventpublish", data, opts) @@ -362,6 +374,18 @@ func GetRTInfoCommand(w *wshutil.WshRpc, data wshrpc.CommandGetRTInfoData, opts return resp, err } +// command "getsecrets", wshserver.GetSecretsCommand +func GetSecretsCommand(w *wshutil.WshRpc, data []string, opts *wshrpc.RpcOpts) (map[string]string, error) { + resp, err := sendRpcRequestCallHelper[map[string]string](w, "getsecrets", data, opts) + return resp, err +} + +// command "getsecretsnames", wshserver.GetSecretsNamesCommand +func GetSecretsNamesCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]string, error) { + resp, err := sendRpcRequestCallHelper[[]string](w, "getsecretsnames", nil, opts) + return resp, err +} + // command "gettab", wshserver.GetTabCommand func GetTabCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) (*waveobj.Tab, error) { resp, err := sendRpcRequestCallHelper[*waveobj.Tab](w, "gettab", data, opts) @@ -568,6 +592,12 @@ func SetRTInfoCommand(w *wshutil.WshRpc, data wshrpc.CommandSetRTInfoData, opts return err } +// command "setsecrets", wshserver.SetSecretsCommand +func SetSecretsCommand(w *wshutil.WshRpc, data map[string]string, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "setsecrets", data, opts) + return err +} + // command "setvar", wshserver.SetVarCommand func SetVarCommand(w *wshutil.WshRpc, data wshrpc.CommandVarData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "setvar", data, opts) diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index a53e0aa441..adb3e6339b 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -165,6 +165,15 @@ const ( Command_StartBuilder = "startbuilder" Command_GetBuilderStatus = "getbuilderstatus" Command_GetBuilderOutput = "getbuilderoutput" + + // electron + Command_ElectronEncrypt = "electronencrypt" + Command_ElectronDecrypt = "electrondecrypt" + + // secrets + Command_GetSecrets = "getsecrets" + Command_GetSecretsNames = "getsecretsnames" + Command_SetSecrets = "setsecrets" ) type RespOrErrorUnion[T any] struct { @@ -275,6 +284,13 @@ type WshRpcInterface interface { WebSelectorCommand(ctx context.Context, data CommandWebSelectorData) ([]string, error) NotifyCommand(ctx context.Context, notificationOptions WaveNotificationOptions) error FocusWindowCommand(ctx context.Context, windowId string) error + ElectronEncryptCommand(ctx context.Context, data CommandElectronEncryptData) (*CommandElectronEncryptRtnData, error) + ElectronDecryptCommand(ctx context.Context, data CommandElectronDecryptData) (*CommandElectronDecryptRtnData, error) + + // secrets + GetSecretsCommand(ctx context.Context, names []string) (map[string]string, error) + GetSecretsNamesCommand(ctx context.Context) ([]string, error) + SetSecretsCommand(ctx context.Context, secrets map[string]string) error WorkspaceListCommand(ctx context.Context) ([]WorkspaceInfoData, error) GetUpdateChannelCommand(ctx context.Context) (string, error) @@ -986,3 +1002,20 @@ type BuilderStatusData struct { ErrorMsg string `json:"errormsg,omitempty"` Version int `json:"version"` } + +type CommandElectronEncryptData struct { + PlainText string `json:"plaintext"` +} + +type CommandElectronEncryptRtnData struct { + CipherText string `json:"ciphertext"` + StorageBackend string `json:"storagebackend"` +} + +type CommandElectronDecryptData struct { + CipherText string `json:"ciphertext"` +} + +type CommandElectronDecryptRtnData struct { + PlainText string `json:"plaintext"` +} diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index 73639c42a7..94629a0d64 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -34,6 +34,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/remote/awsconn" "github.com/wavetermdev/waveterm/pkg/remote/conncontroller" "github.com/wavetermdev/waveterm/pkg/remote/fileshare" + "github.com/wavetermdev/waveterm/pkg/secretstore" "github.com/wavetermdev/waveterm/pkg/suggestion" "github.com/wavetermdev/waveterm/pkg/telemetry" "github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata" @@ -1240,3 +1241,35 @@ func (ws *WshServer) GetTabCommand(ctx context.Context, tabId string) (*waveobj. } return tab, nil } + +func (ws *WshServer) GetSecretsCommand(ctx context.Context, names []string) (map[string]string, error) { + result := make(map[string]string) + for _, name := range names { + value, exists, err := secretstore.GetSecret(name) + if err != nil { + return nil, fmt.Errorf("error getting secret %q: %w", name, err) + } + if exists { + result[name] = value + } + } + return result, nil +} + +func (ws *WshServer) GetSecretsNamesCommand(ctx context.Context) ([]string, error) { + names, err := secretstore.GetSecretNames() + if err != nil { + return nil, fmt.Errorf("error getting secret names: %w", err) + } + return names, nil +} + +func (ws *WshServer) SetSecretsCommand(ctx context.Context, secrets map[string]string) error { + for name, value := range secrets { + err := secretstore.SetSecret(name, value) + if err != nil { + return fmt.Errorf("error setting secret %q: %w", name, err) + } + } + return nil +} From 6baf5a92bd21822331b5d16b438542c98027fdc9 Mon Sep 17 00:00:00 2001 From: sawka Date: Wed, 5 Nov 2025 14:13:14 -0800 Subject: [PATCH 2/4] add wshcmd secret to manage secrets from the CLI --- cmd/wsh/cmd/wshcmd-secret.go | 137 +++++++++++++++++++++++++++++++++ package-lock.json | 4 +- pkg/secretstore/secretstore.go | 10 ++- 3 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 cmd/wsh/cmd/wshcmd-secret.go diff --git a/cmd/wsh/cmd/wshcmd-secret.go b/cmd/wsh/cmd/wshcmd-secret.go new file mode 100644 index 0000000000..f54d268595 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-secret.go @@ -0,0 +1,137 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" + + "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" +) + +// secretNameRegex must match the validation in pkg/wconfig/secretstore.go +var secretNameRegex = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]*$`) + +var secretCmd = &cobra.Command{ + Use: "secret", + Short: "manage secrets", + Long: "Manage secrets for Wave Terminal", +} + +var secretGetCmd = &cobra.Command{ + Use: "get [name]", + Short: "get a secret value", + Args: cobra.ExactArgs(1), + RunE: secretGetRun, + PreRunE: preRunSetupRpcClient, +} + +var secretSetCmd = &cobra.Command{ + Use: "set [name]=[value]", + Short: "set a secret value", + Args: cobra.ExactArgs(1), + RunE: secretSetRun, + PreRunE: preRunSetupRpcClient, +} + +var secretListCmd = &cobra.Command{ + Use: "list", + Short: "list all secret names", + Args: cobra.NoArgs, + RunE: secretListRun, + PreRunE: preRunSetupRpcClient, +} + +var secretGetRawOutput bool + +func init() { + rootCmd.AddCommand(secretCmd) + secretCmd.AddCommand(secretGetCmd) + secretCmd.AddCommand(secretSetCmd) + secretCmd.AddCommand(secretListCmd) + + secretGetCmd.Flags().BoolVar(&secretGetRawOutput, "raw", false, "output value without quotes") +} + +func secretGetRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("secret", rtnErr == nil) + }() + + name := args[0] + if !secretNameRegex.MatchString(name) { + return fmt.Errorf("invalid secret name: must start with a letter and contain only letters, numbers, and underscores") + } + + resp, err := wshclient.GetSecretsCommand(RpcClient, []string{name}, &wshrpc.RpcOpts{Timeout: 2000}) + if err != nil { + return fmt.Errorf("getting secret: %w", err) + } + + value, ok := resp[name] + if !ok { + return fmt.Errorf("secret not found: %s", name) + } + + if secretGetRawOutput { + WriteStdout("%s\n", value) + return nil + } + + outBArr, err := json.MarshalIndent(value, "", " ") + if err != nil { + return fmt.Errorf("formatting secret: %w", err) + } + WriteStdout("%s\n", string(outBArr)) + return nil +} + +func secretSetRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("secret", rtnErr == nil) + }() + + parts := strings.SplitN(args[0], "=", 2) + if len(parts) != 2 { + return fmt.Errorf("invalid format: expected [name]=[value]") + } + + name := parts[0] + value := parts[1] + + if name == "" { + return fmt.Errorf("secret name cannot be empty") + } + + secrets := map[string]string{name: value} + err := wshclient.SetSecretsCommand(RpcClient, secrets, &wshrpc.RpcOpts{Timeout: 2000}) + if err != nil { + return fmt.Errorf("setting secret: %w", err) + } + + WriteStdout("secret set: %s\n", name) + return nil +} + +func secretListRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("secret", rtnErr == nil) + }() + + names, err := wshclient.GetSecretsNamesCommand(RpcClient, &wshrpc.RpcOpts{Timeout: 2000}) + if err != nil { + return fmt.Errorf("listing secrets: %w", err) + } + + outBArr, err := json.MarshalIndent(names, "", " ") + if err != nil { + return fmt.Errorf("formatting secret names: %w", err) + } + WriteStdout("%s\n", string(outBArr)) + return nil +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 6f653bc1c4..f5604304b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "waveterm", - "version": "0.12.2-beta.4", + "version": "0.12.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "waveterm", - "version": "0.12.2-beta.4", + "version": "0.12.2", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ diff --git a/pkg/secretstore/secretstore.go b/pkg/secretstore/secretstore.go index 1f388e6b7b..78f594dfb0 100644 --- a/pkg/secretstore/secretstore.go +++ b/pkg/secretstore/secretstore.go @@ -26,6 +26,7 @@ const ( EncryptionTimeout = 5000 InitRetryMs = 1000 SecretNamePattern = `^[A-Za-z][A-Za-z0-9_]*$` + WriteTsKey = "wave:writets" ) var lock sync.Mutex @@ -120,10 +121,11 @@ func writerLoop() { func writeSecretsToFile() error { lock.Lock() - secretsCopy := make(map[string]string, len(secrets)) + secretsCopy := make(map[string]string, len(secrets)+1) for k, v := range secrets { secretsCopy[k] = v } + secretsCopy[WriteTsKey] = time.Now().UTC().Format(time.RFC3339) lock.Unlock() jsonData, err := json.Marshal(secretsCopy) @@ -188,6 +190,9 @@ func SetSecret(name string, value string) error { } func GetSecret(name string) (string, bool, error) { + if name == WriteTsKey { + return "", false, nil + } if err := initSecretStore(); err != nil { return "", false, err } @@ -207,6 +212,9 @@ func GetSecretNames() ([]string, error) { names := make([]string, 0, len(secrets)) for name := range secrets { + if name == WriteTsKey { + continue + } names = append(names, name) } return names, nil From b9ada1cc6ad40f77340c12fcf7ac981275b220b7 Mon Sep 17 00:00:00 2001 From: sawka Date: Wed, 5 Nov 2025 14:50:57 -0800 Subject: [PATCH 3/4] refuse to set secrets on linux if an appropriate secret backend is not found --- cmd/wsh/cmd/wshcmd-secret.go | 33 ++++++-------- emain/emain-wsh.ts | 6 +++ frontend/app/store/wshclientapi.ts | 5 +++ frontend/types/gotypes.d.ts | 1 + pkg/secretstore/secretstore.go | 71 ++++++++++++++++++++++++++++++ pkg/wshrpc/wshclient/wshclient.go | 6 +++ pkg/wshrpc/wshrpctypes.go | 17 ++++--- pkg/wshrpc/wshserver/wshserver.go | 8 ++++ 8 files changed, 120 insertions(+), 27 deletions(-) diff --git a/cmd/wsh/cmd/wshcmd-secret.go b/cmd/wsh/cmd/wshcmd-secret.go index f54d268595..14b0ea2781 100644 --- a/cmd/wsh/cmd/wshcmd-secret.go +++ b/cmd/wsh/cmd/wshcmd-secret.go @@ -4,7 +4,6 @@ package cmd import ( - "encoding/json" "fmt" "regexp" "strings" @@ -47,15 +46,11 @@ var secretListCmd = &cobra.Command{ PreRunE: preRunSetupRpcClient, } -var secretGetRawOutput bool - func init() { rootCmd.AddCommand(secretCmd) secretCmd.AddCommand(secretGetCmd) secretCmd.AddCommand(secretSetCmd) secretCmd.AddCommand(secretListCmd) - - secretGetCmd.Flags().BoolVar(&secretGetRawOutput, "raw", false, "output value without quotes") } func secretGetRun(cmd *cobra.Command, args []string) (rtnErr error) { @@ -78,16 +73,7 @@ func secretGetRun(cmd *cobra.Command, args []string) (rtnErr error) { return fmt.Errorf("secret not found: %s", name) } - if secretGetRawOutput { - WriteStdout("%s\n", value) - return nil - } - - outBArr, err := json.MarshalIndent(value, "", " ") - if err != nil { - return fmt.Errorf("formatting secret: %w", err) - } - WriteStdout("%s\n", string(outBArr)) + WriteStdout("%s\n", value) return nil } @@ -108,8 +94,17 @@ func secretSetRun(cmd *cobra.Command, args []string) (rtnErr error) { return fmt.Errorf("secret name cannot be empty") } + backend, err := wshclient.GetSecretsLinuxStorageBackendCommand(RpcClient, &wshrpc.RpcOpts{Timeout: 2000}) + if err != nil { + return fmt.Errorf("checking secret storage backend: %w", err) + } + + if backend == "basic_text" || backend == "unknown" { + return fmt.Errorf("No appropriate secret manager found, cannot set secrets") + } + secrets := map[string]string{name: value} - err := wshclient.SetSecretsCommand(RpcClient, secrets, &wshrpc.RpcOpts{Timeout: 2000}) + err = wshclient.SetSecretsCommand(RpcClient, secrets, &wshrpc.RpcOpts{Timeout: 2000}) if err != nil { return fmt.Errorf("setting secret: %w", err) } @@ -128,10 +123,8 @@ func secretListRun(cmd *cobra.Command, args []string) (rtnErr error) { return fmt.Errorf("listing secrets: %w", err) } - outBArr, err := json.MarshalIndent(names, "", " ") - if err != nil { - return fmt.Errorf("formatting secret names: %w", err) + for _, name := range names { + WriteStdout("%s\n", name) } - WriteStdout("%s\n", string(outBArr)) return nil } \ No newline at end of file diff --git a/emain/emain-wsh.ts b/emain/emain-wsh.ts index 5452ad76fb..639f4dfd35 100644 --- a/emain/emain-wsh.ts +++ b/emain/emain-wsh.ts @@ -91,8 +91,14 @@ export class ElectronWshClientType extends WshClient { const encrypted = Buffer.from(data.ciphertext, "base64"); const plaintext = safeStorage.decryptString(encrypted); + let storagebackend = ""; + if (process.platform === "linux") { + storagebackend = safeStorage.getSelectedStorageBackend(); + } + return { plaintext, + storagebackend, }; } diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 4bbcdced36..5b297ad441 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -312,6 +312,11 @@ class RpcApiType { return client.wshRpcCall("getsecrets", data, opts); } + // command "getsecretslinuxstoragebackend" [call] + GetSecretsLinuxStorageBackendCommand(client: WshClient, opts?: RpcOpts): Promise { + return client.wshRpcCall("getsecretslinuxstoragebackend", null, opts); + } + // command "getsecretsnames" [call] GetSecretsNamesCommand(client: WshClient, opts?: RpcOpts): Promise { return client.wshRpcCall("getsecretsnames", null, opts); diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 31f5700ad9..11dd2bfed3 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -235,6 +235,7 @@ declare global { // wshrpc.CommandElectronDecryptRtnData type CommandElectronDecryptRtnData = { plaintext: string; + storagebackend: string; }; // wshrpc.CommandElectronEncryptData diff --git a/pkg/secretstore/secretstore.go b/pkg/secretstore/secretstore.go index 78f594dfb0..57854b779d 100644 --- a/pkg/secretstore/secretstore.go +++ b/pkg/secretstore/secretstore.go @@ -11,6 +11,7 @@ import ( "os" "path/filepath" "regexp" + "runtime" "sync" "time" @@ -36,7 +37,43 @@ var initialized bool var lastInitTryTime time.Time var lastInitErr error var secretNameRegexp = regexp.MustCompile(SecretNamePattern) +var linuxStorageBackend string +// must hold lock +func getLinuxStorageBackend() error { + if runtime.GOOS != "linux" { + return nil + } + + rpcClient := wshclient.GetBareRpcClient() + ctx, cancel := context.WithTimeout(context.Background(), EncryptionTimeout*time.Millisecond) + defer cancel() + + encryptData := wshrpc.CommandElectronEncryptData{ + PlainText: "hello", + } + rpcOpts := &wshrpc.RpcOpts{ + Route: wshutil.ElectronRoute, + Timeout: EncryptionTimeout, + } + + result, err := wshclient.ElectronEncryptCommand(rpcClient, encryptData, rpcOpts) + if err != nil { + return fmt.Errorf("failed to get storage backend: %w", err) + } + + if ctx.Err() != nil { + return fmt.Errorf("encryption timeout: %w", ctx.Err()) + } + + if result.StorageBackend != "" { + linuxStorageBackend = result.StorageBackend + } + + return nil +} + +// must hold lock func readSecretsFromFile() (map[string]string, error) { configDir := wavebase.GetWaveConfigDir() secretsPath := filepath.Join(configDir, SecretsFileName) @@ -46,6 +83,9 @@ func readSecretsFromFile() (map[string]string, error) { if !os.IsNotExist(err) { log.Printf("secretstore: could not read secrets file: %v\n", err) } + if err := getLinuxStorageBackend(); err != nil { + log.Printf("secretstore: could not get linux storage backend: %v\n", err) + } return make(map[string]string), nil } @@ -70,6 +110,10 @@ func readSecretsFromFile() (map[string]string, error) { return nil, fmt.Errorf("decryption timeout: %w", ctx.Err()) } + if result.StorageBackend != "" { + linuxStorageBackend = result.StorageBackend + } + var decryptedSecrets map[string]string if err := json.Unmarshal([]byte(result.PlainText), &decryptedSecrets); err != nil { return nil, fmt.Errorf("failed to parse secrets: %w", err) @@ -154,6 +198,10 @@ func writeSecretsToFile() error { return fmt.Errorf("encryption timeout: %w", ctx.Err()) } + if result.StorageBackend != "" { + linuxStorageBackend = result.StorageBackend + } + configDir := wavebase.GetWaveConfigDir() secretsPath := filepath.Join(configDir, SecretsFileName) @@ -219,3 +267,26 @@ func GetSecretNames() ([]string, error) { } return names, nil } + +func GetLinuxStorageBackend() (string, error) { + if runtime.GOOS != "linux" { + return "", nil + } + + lock.Lock() + defer lock.Unlock() + + if linuxStorageBackend != "" { + return linuxStorageBackend, nil + } + + if err := getLinuxStorageBackend(); err != nil { + return "", err + } + + if linuxStorageBackend == "" { + return "", fmt.Errorf("failed to determine linux storage backend") + } + + return linuxStorageBackend, nil +} diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index 1dc1f9bf95..2505f2c192 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -380,6 +380,12 @@ func GetSecretsCommand(w *wshutil.WshRpc, data []string, opts *wshrpc.RpcOpts) ( return resp, err } +// command "getsecretslinuxstoragebackend", wshserver.GetSecretsLinuxStorageBackendCommand +func GetSecretsLinuxStorageBackendCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (string, error) { + resp, err := sendRpcRequestCallHelper[string](w, "getsecretslinuxstoragebackend", nil, opts) + return resp, err +} + // command "getsecretsnames", wshserver.GetSecretsNamesCommand func GetSecretsNamesCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]string, error) { resp, err := sendRpcRequestCallHelper[[]string](w, "getsecretsnames", nil, opts) diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index adb3e6339b..c5d96088ec 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -171,9 +171,10 @@ const ( Command_ElectronDecrypt = "electrondecrypt" // secrets - Command_GetSecrets = "getsecrets" - Command_GetSecretsNames = "getsecretsnames" - Command_SetSecrets = "setsecrets" + Command_GetSecrets = "getsecrets" + Command_GetSecretsNames = "getsecretsnames" + Command_SetSecrets = "setsecrets" + Command_GetSecretsLinuxStorageBackend = "getsecretslinuxstoragebackend" ) type RespOrErrorUnion[T any] struct { @@ -291,6 +292,7 @@ type WshRpcInterface interface { GetSecretsCommand(ctx context.Context, names []string) (map[string]string, error) GetSecretsNamesCommand(ctx context.Context) ([]string, error) SetSecretsCommand(ctx context.Context, secrets map[string]string) error + GetSecretsLinuxStorageBackendCommand(ctx context.Context) (string, error) WorkspaceListCommand(ctx context.Context) ([]WorkspaceInfoData, error) GetUpdateChannelCommand(ctx context.Context) (string, error) @@ -616,8 +618,8 @@ type CommandFileCopyData struct { } type CommandFileRestoreBackupData struct { - BackupFilePath string `json:"backupfilepath"` - RestoreToFileName string `json:"restoretofilename"` + BackupFilePath string `json:"backupfilepath"` + RestoreToFileName string `json:"restoretofilename"` } type CommandRemoteStreamTarData struct { @@ -1009,7 +1011,7 @@ type CommandElectronEncryptData struct { type CommandElectronEncryptRtnData struct { CipherText string `json:"ciphertext"` - StorageBackend string `json:"storagebackend"` + StorageBackend string `json:"storagebackend"` // only returned for linux } type CommandElectronDecryptData struct { @@ -1017,5 +1019,6 @@ type CommandElectronDecryptData struct { } type CommandElectronDecryptRtnData struct { - PlainText string `json:"plaintext"` + PlainText string `json:"plaintext"` + StorageBackend string `json:"storagebackend"` // only returned for linux } diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index 94629a0d64..df7f7a29c1 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -1273,3 +1273,11 @@ func (ws *WshServer) SetSecretsCommand(ctx context.Context, secrets map[string]s } return nil } + +func (ws *WshServer) GetSecretsLinuxStorageBackendCommand(ctx context.Context) (string, error) { + backend, err := secretstore.GetLinuxStorageBackend() + if err != nil { + return "", fmt.Errorf("error getting linux storage backend: %w", err) + } + return backend, nil +} From 6479fe6603ef6d3b48ffc88c22722c49b9fb9b6a Mon Sep 17 00:00:00 2001 From: sawka Date: Wed, 5 Nov 2025 15:21:08 -0800 Subject: [PATCH 4/4] dont set the store type on write... (was out of lock, and also unnecessary since we always get it on read) --- pkg/secretstore/secretstore.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/pkg/secretstore/secretstore.go b/pkg/secretstore/secretstore.go index 57854b779d..0621e34672 100644 --- a/pkg/secretstore/secretstore.go +++ b/pkg/secretstore/secretstore.go @@ -198,10 +198,6 @@ func writeSecretsToFile() error { return fmt.Errorf("encryption timeout: %w", ctx.Err()) } - if result.StorageBackend != "" { - linuxStorageBackend = result.StorageBackend - } - configDir := wavebase.GetWaveConfigDir() secretsPath := filepath.Join(configDir, SecretsFileName)