Skip to content

Commit 58392f7

Browse files
authored
new secret store impl (#2525)
adds a new secret store protected by electron's safeStorage API. currently just hooked up to the CLI via a new wsh command: `wsh secret`. will be used to power secrets in tsunami later (and for config + connections)
1 parent 8ab15ef commit 58392f7

File tree

8 files changed

+628
-3
lines changed

8 files changed

+628
-3
lines changed

cmd/wsh/cmd/wshcmd-secret.go

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
// Copyright 2025, Command Line Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package cmd
5+
6+
import (
7+
"fmt"
8+
"regexp"
9+
"strings"
10+
11+
"github.com/spf13/cobra"
12+
"github.com/wavetermdev/waveterm/pkg/wshrpc"
13+
"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient"
14+
)
15+
16+
// secretNameRegex must match the validation in pkg/wconfig/secretstore.go
17+
var secretNameRegex = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]*$`)
18+
19+
var secretCmd = &cobra.Command{
20+
Use: "secret",
21+
Short: "manage secrets",
22+
Long: "Manage secrets for Wave Terminal",
23+
}
24+
25+
var secretGetCmd = &cobra.Command{
26+
Use: "get [name]",
27+
Short: "get a secret value",
28+
Args: cobra.ExactArgs(1),
29+
RunE: secretGetRun,
30+
PreRunE: preRunSetupRpcClient,
31+
}
32+
33+
var secretSetCmd = &cobra.Command{
34+
Use: "set [name]=[value]",
35+
Short: "set a secret value",
36+
Args: cobra.ExactArgs(1),
37+
RunE: secretSetRun,
38+
PreRunE: preRunSetupRpcClient,
39+
}
40+
41+
var secretListCmd = &cobra.Command{
42+
Use: "list",
43+
Short: "list all secret names",
44+
Args: cobra.NoArgs,
45+
RunE: secretListRun,
46+
PreRunE: preRunSetupRpcClient,
47+
}
48+
49+
func init() {
50+
rootCmd.AddCommand(secretCmd)
51+
secretCmd.AddCommand(secretGetCmd)
52+
secretCmd.AddCommand(secretSetCmd)
53+
secretCmd.AddCommand(secretListCmd)
54+
}
55+
56+
func secretGetRun(cmd *cobra.Command, args []string) (rtnErr error) {
57+
defer func() {
58+
sendActivity("secret", rtnErr == nil)
59+
}()
60+
61+
name := args[0]
62+
if !secretNameRegex.MatchString(name) {
63+
return fmt.Errorf("invalid secret name: must start with a letter and contain only letters, numbers, and underscores")
64+
}
65+
66+
resp, err := wshclient.GetSecretsCommand(RpcClient, []string{name}, &wshrpc.RpcOpts{Timeout: 2000})
67+
if err != nil {
68+
return fmt.Errorf("getting secret: %w", err)
69+
}
70+
71+
value, ok := resp[name]
72+
if !ok {
73+
return fmt.Errorf("secret not found: %s", name)
74+
}
75+
76+
WriteStdout("%s\n", value)
77+
return nil
78+
}
79+
80+
func secretSetRun(cmd *cobra.Command, args []string) (rtnErr error) {
81+
defer func() {
82+
sendActivity("secret", rtnErr == nil)
83+
}()
84+
85+
parts := strings.SplitN(args[0], "=", 2)
86+
if len(parts) != 2 {
87+
return fmt.Errorf("invalid format: expected [name]=[value]")
88+
}
89+
90+
name := parts[0]
91+
value := parts[1]
92+
93+
if name == "" {
94+
return fmt.Errorf("secret name cannot be empty")
95+
}
96+
97+
backend, err := wshclient.GetSecretsLinuxStorageBackendCommand(RpcClient, &wshrpc.RpcOpts{Timeout: 2000})
98+
if err != nil {
99+
return fmt.Errorf("checking secret storage backend: %w", err)
100+
}
101+
102+
if backend == "basic_text" || backend == "unknown" {
103+
return fmt.Errorf("No appropriate secret manager found, cannot set secrets")
104+
}
105+
106+
secrets := map[string]string{name: value}
107+
err = wshclient.SetSecretsCommand(RpcClient, secrets, &wshrpc.RpcOpts{Timeout: 2000})
108+
if err != nil {
109+
return fmt.Errorf("setting secret: %w", err)
110+
}
111+
112+
WriteStdout("secret set: %s\n", name)
113+
return nil
114+
}
115+
116+
func secretListRun(cmd *cobra.Command, args []string) (rtnErr error) {
117+
defer func() {
118+
sendActivity("secret", rtnErr == nil)
119+
}()
120+
121+
names, err := wshclient.GetSecretsNamesCommand(RpcClient, &wshrpc.RpcOpts{Timeout: 2000})
122+
if err != nil {
123+
return fmt.Errorf("listing secrets: %w", err)
124+
}
125+
126+
for _, name := range names {
127+
WriteStdout("%s\n", name)
128+
}
129+
return nil
130+
}

emain/emain-wsh.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import { WindowService } from "@/app/store/services";
55
import { RpcResponseHelper, WshClient } from "@/app/store/wshclient";
66
import { RpcApi } from "@/app/store/wshclientapi";
7-
import { Notification } from "electron";
7+
import { Notification, safeStorage } from "electron";
88
import { getResolvedUpdateChannel } from "emain/updater";
99
import { unamePlatform } from "./emain-platform";
1010
import { getWebContentsByBlockId, webGetSelector } from "./emain-web";
@@ -60,6 +60,48 @@ export class ElectronWshClientType extends WshClient {
6060
ww.focus();
6161
}
6262

63+
async handle_electronencrypt(
64+
rh: RpcResponseHelper,
65+
data: CommandElectronEncryptData
66+
): Promise<CommandElectronEncryptRtnData> {
67+
if (!safeStorage.isEncryptionAvailable()) {
68+
throw new Error("encryption is not available");
69+
}
70+
const encrypted = safeStorage.encryptString(data.plaintext);
71+
const ciphertext = encrypted.toString("base64");
72+
73+
let storagebackend = "";
74+
if (process.platform === "linux") {
75+
storagebackend = safeStorage.getSelectedStorageBackend();
76+
}
77+
78+
return {
79+
ciphertext,
80+
storagebackend,
81+
};
82+
}
83+
84+
async handle_electrondecrypt(
85+
rh: RpcResponseHelper,
86+
data: CommandElectronDecryptData
87+
): Promise<CommandElectronDecryptRtnData> {
88+
if (!safeStorage.isEncryptionAvailable()) {
89+
throw new Error("encryption is not available");
90+
}
91+
const encrypted = Buffer.from(data.ciphertext, "base64");
92+
const plaintext = safeStorage.decryptString(encrypted);
93+
94+
let storagebackend = "";
95+
if (process.platform === "linux") {
96+
storagebackend = safeStorage.getSelectedStorageBackend();
97+
}
98+
99+
return {
100+
plaintext,
101+
storagebackend,
102+
};
103+
}
104+
63105
// async handle_workspaceupdate(rh: RpcResponseHelper) {
64106
// console.log("workspaceupdate");
65107
// fireAndForget(async () => {

frontend/app/store/wshclientapi.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,16 @@ class RpcApiType {
147147
return client.wshRpcCall("disposesuggestions", data, opts);
148148
}
149149

150+
// command "electrondecrypt" [call]
151+
ElectronDecryptCommand(client: WshClient, data: CommandElectronDecryptData, opts?: RpcOpts): Promise<CommandElectronDecryptRtnData> {
152+
return client.wshRpcCall("electrondecrypt", data, opts);
153+
}
154+
155+
// command "electronencrypt" [call]
156+
ElectronEncryptCommand(client: WshClient, data: CommandElectronEncryptData, opts?: RpcOpts): Promise<CommandElectronEncryptRtnData> {
157+
return client.wshRpcCall("electronencrypt", data, opts);
158+
}
159+
150160
// command "eventpublish" [call]
151161
EventPublishCommand(client: WshClient, data: WaveEvent, opts?: RpcOpts): Promise<void> {
152162
return client.wshRpcCall("eventpublish", data, opts);
@@ -297,6 +307,21 @@ class RpcApiType {
297307
return client.wshRpcCall("getrtinfo", data, opts);
298308
}
299309

310+
// command "getsecrets" [call]
311+
GetSecretsCommand(client: WshClient, data: string[], opts?: RpcOpts): Promise<{[key: string]: string}> {
312+
return client.wshRpcCall("getsecrets", data, opts);
313+
}
314+
315+
// command "getsecretslinuxstoragebackend" [call]
316+
GetSecretsLinuxStorageBackendCommand(client: WshClient, opts?: RpcOpts): Promise<string> {
317+
return client.wshRpcCall("getsecretslinuxstoragebackend", null, opts);
318+
}
319+
320+
// command "getsecretsnames" [call]
321+
GetSecretsNamesCommand(client: WshClient, opts?: RpcOpts): Promise<string[]> {
322+
return client.wshRpcCall("getsecretsnames", null, opts);
323+
}
324+
300325
// command "gettab" [call]
301326
GetTabCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<Tab> {
302327
return client.wshRpcCall("gettab", data, opts);
@@ -472,6 +497,11 @@ class RpcApiType {
472497
return client.wshRpcCall("setrtinfo", data, opts);
473498
}
474499

500+
// command "setsecrets" [call]
501+
SetSecretsCommand(client: WshClient, data: {[key: string]: string}, opts?: RpcOpts): Promise<void> {
502+
return client.wshRpcCall("setsecrets", data, opts);
503+
}
504+
475505
// command "setvar" [call]
476506
SetVarCommand(client: WshClient, data: CommandVarData, opts?: RpcOpts): Promise<void> {
477507
return client.wshRpcCall("setvar", data, opts);

frontend/types/gotypes.d.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,28 @@ declare global {
227227
routeid: string;
228228
};
229229

230+
// wshrpc.CommandElectronDecryptData
231+
type CommandElectronDecryptData = {
232+
ciphertext: string;
233+
};
234+
235+
// wshrpc.CommandElectronDecryptRtnData
236+
type CommandElectronDecryptRtnData = {
237+
plaintext: string;
238+
storagebackend: string;
239+
};
240+
241+
// wshrpc.CommandElectronEncryptData
242+
type CommandElectronEncryptData = {
243+
plaintext: string;
244+
};
245+
246+
// wshrpc.CommandElectronEncryptRtnData
247+
type CommandElectronEncryptRtnData = {
248+
ciphertext: string;
249+
storagebackend: string;
250+
};
251+
230252
// wshrpc.CommandEventReadHistoryData
231253
type CommandEventReadHistoryData = {
232254
event: string;

0 commit comments

Comments
 (0)