Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions apps/frontend/electron.main.js
Original file line number Diff line number Diff line change
Expand Up @@ -126,10 +126,16 @@ powerMonitor.on("resume", () => {
})
})

ipcMain.handle("secure-storage:is-encryption-available", async () => safeStorage.isEncryptionAvailable())

ipcMain.handle("secure-storage:encrypt", async (event, password) => {
if (!password || !safeStorage.isEncryptionAvailable()) return password
const encrypted = safeStorage.encryptString(password)
return encrypted.toString("base64")
if (!password) return { ok: true, value: "" }
// Fail closed: never return the plaintext password when the OS has no secure
// store, so the renderer can't persist an unprotected secret while implying it
// is encrypted. The caller keeps the plaintext for the live connection and marks
// it do-not-persist.
if (!safeStorage.isEncryptionAvailable()) return { ok: false }
return { ok: true, value: safeStorage.encryptString(password).toString("base64") }
})

ipcMain.handle("secure-storage:decrypt", async (event, encryptedBase64) => {
Expand Down
1 change: 1 addition & 0 deletions apps/frontend/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const { contextBridge, ipcRenderer } = require("electron")
contextBridge.exposeInMainWorld("secureStorage", {
encrypt: (password) => ipcRenderer.invoke("secure-storage:encrypt", password),
decrypt: (encrypted) => ipcRenderer.invoke("secure-storage:decrypt", encrypted),
isEncryptionAvailable: () => ipcRenderer.invoke("secure-storage:is-encryption-available"),
})

contextBridge.exposeInMainWorld("electronNavigation", {
Expand Down
9 changes: 6 additions & 3 deletions apps/frontend/src/components/ValkeyReconnect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ import { useNavigate, useParams } from "react-router"
import { CONNECTED, CONNECTING, ERROR } from "@common/src/constants"
import { Loader2, Database, AlertCircle } from "lucide-react"
import * as R from "ramda"
import { toast } from "sonner"
import RetryProgress from "./ui/retry-progress"
import { PasswordPromptModal } from "./ui/password-prompt-modal"
import type { RootState } from "@/store"
import { connectPending } from "@/state/valkey-features/connection/connectionSlice"
import { secureStorage } from "@/utils/secureStorage"
import { secureStorage, PASSWORD_NOT_STORED_WARNING } from "@/utils/secureStorage"

export function ValkeyReconnect() {
const dispatch = useDispatch()
Expand Down Expand Up @@ -51,10 +52,12 @@ export function ValkeyReconnect() {

const handlePasswordSubmit = async (password: string) => {
if (!connection) return
const encryptedPassword = await secureStorage.encryptIfAvailable(password)
const result = await secureStorage.encryptForStorage(password)
if (!result.ok) toast.warning(PASSWORD_NOT_STORED_WARNING)
dispatch(connectPending({
connectionId: id!,
connectionDetails: { ...connection.connectionDetails, password: encryptedPassword },
connectionDetails: { ...connection.connectionDetails, password: result.ok ? result.value : password },
isPasswordEncrypted: result.ok,
}))
}

Expand Down
25 changes: 15 additions & 10 deletions apps/frontend/src/components/cluster-topology/cluster-node-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { buildConnectionId } from "@common/src/connection-id.ts"
import { calculateHitRatio } from "@common/src/cache-hit-ratio.ts"
import { formatBytes } from "@common/src/bytes-conversion.ts"
import { TooltipProvider } from "@radix-ui/react-tooltip"
import { toast } from "sonner"
import { Badge } from "../ui/badge"
import { CustomTooltip } from "../ui/tooltip"
import { Button } from "../ui/button"
Expand All @@ -21,9 +22,9 @@ import { getUtilizationLevel, type UtilizationLevel } from "@/state/valkey-featu
import { connectPending, type ConnectionDetails } from "@/state/valkey-features/connection/connectionSlice.ts"
import { useAppDispatch } from "@/hooks/hooks"
import {
selectIsAtConnectionLimit, selectEncryptedPassword, selectClusterDb
selectIsAtConnectionLimit, selectClusterPassword, selectClusterDb
} from "@/state/valkey-features/connection/connectionSelectors"
import { secureStorage } from "@/utils/secureStorage.ts"
import { secureStorage, PASSWORD_NOT_STORED_WARNING } from "@/utils/secureStorage.ts"
import { cn } from "@/lib/utils"

const UTILIZATION_BADGE: Record<UtilizationLevel, { label: string, variant: "secondary" | "success" | "destructive" }> = {
Expand Down Expand Up @@ -79,9 +80,9 @@ export function ClusterNodeRow({

const isDisabled = useSelector(selectIsAtConnectionLimit)

// Look up encrypted password from an existing connection in the same cluster.
// Available when secureStorage was active during the original connection.
const encryptedPassword = useSelector(selectEncryptedPassword(clusterId))
// Look up a stored password from an existing connection in the same cluster,
// together with its isPasswordEncrypted marking.
const clusterPassword = useSelector(selectClusterPassword(clusterId))

const [showPasswordModal, setShowPasswordModal] = useState(false)

Expand Down Expand Up @@ -109,15 +110,17 @@ export function ClusterNodeRow({
awsReplicationGroupId: primaryConfig.awsReplicationGroupId,
},
}))
} else if (R.isNotNil(encryptedPassword)) {
// Password already encrypted from existing cluster connection — do NOT re-encrypt
} else if (R.isNotNil(clusterPassword)) {
// Reuse the sibling connection's stored password, carrying its
// isPasswordEncrypted marking so an unencrypted one is still never persisted.
dispatch(connectPending({
connectionId,
connectionDetails: {
...baseDetails,
username: primaryConfig.username ?? "",
password: encryptedPassword,
password: clusterPassword.password,
},
isPasswordEncrypted: clusterPassword.isPasswordEncrypted,
}))
} else {
// No stored password — prompt for password
Expand All @@ -126,14 +129,16 @@ export function ClusterNodeRow({
}

const handlePasswordSubmit = async (password: string) => {
const encryptedPw = await secureStorage.encryptIfAvailable(password)
const result = await secureStorage.encryptForStorage(password)
if (!result.ok) toast.warning(PASSWORD_NOT_STORED_WARNING)
dispatch(connectPending({
connectionId,
connectionDetails: {
...baseDetails,
username: primaryConfig.username ?? "",
password: encryptedPw,
password: result.ok ? result.value : password,
},
isPasswordEncrypted: result.ok,
}))
}

Expand Down
9 changes: 6 additions & 3 deletions apps/frontend/src/components/connection/Connection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useState } from "react"
import { useSelector } from "react-redux"
import { HousePlug } from "lucide-react"
import { CONNECTED, CONNECTING, MAX_CONNECTIONS, RECONNECTING } from "@common/src/constants.ts"
import { toast } from "sonner"
import ConnectionForm from "../ui/connection-form.tsx"
import EditForm from "../ui/edit-form.tsx"
import { PasswordPromptModal } from "../ui/password-prompt-modal.tsx"
Expand All @@ -17,7 +18,7 @@ import { selectConnections } from "@/state/valkey-features/connection/connection
import { ConnectionEntry } from "@/components/connection/ConnectionEntry.tsx"
import { ClusterConnectionGroup } from "@/components/connection/ClusterConnectionGroup.tsx"
import { useAppDispatch } from "@/hooks/hooks.ts"
import { secureStorage } from "@/utils/secureStorage.ts"
import { secureStorage, PASSWORD_NOT_STORED_WARNING } from "@/utils/secureStorage.ts"

const matchesSearch = (q: string, connection: ConnectionState) =>
connection.searchableText.includes(q)
Expand Down Expand Up @@ -49,10 +50,12 @@ export function Connection() {
if (!passwordPromptConnectionId) return
const connection = connections[passwordPromptConnectionId]
if (!connection) return
const encryptedPassword = await secureStorage.encryptIfAvailable(password)
const result = await secureStorage.encryptForStorage(password)
if (!result.ok) toast.warning(PASSWORD_NOT_STORED_WARNING)
dispatch(connectPending({
connectionId: passwordPromptConnectionId,
connectionDetails: { ...connection.connectionDetails, password: encryptedPassword },
connectionDetails: { ...connection.connectionDetails, password: result.ok ? result.value : password },
isPasswordEncrypted: result.ok,
preservedHistory: connection.connectionHistory,
}))
}
Expand Down
20 changes: 14 additions & 6 deletions apps/frontend/src/components/ui/connection-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { type FormEvent, useEffect, useState } from "react"
import { useSelector } from "react-redux"
import { buildConnectionId, isValidDatabaseIndex } from "@common/src/connection-id.ts"
import { CONNECTED, CONNECTING, ERROR } from "@common/src/constants.ts"
import { toast } from "sonner"
import { ConnectionModal } from "./connection-modal.tsx"
import { useAppDispatch, useAppSelector } from "@/hooks/hooks"
import { connectPending, type ConnectionDetails } from "@/state/valkey-features/connection/connectionSlice.ts"
Expand All @@ -10,7 +11,7 @@ import {
discoveryEndpointPending,
clearEndpointDiscovery
} from "@/state/valkey-features/topology/topologySlice.ts"
import { secureStorage } from "@/utils/secureStorage.ts"
import { secureStorage, PASSWORD_NOT_STORED_WARNING } from "@/utils/secureStorage.ts"

interface ConnectionFormProps {
onClose: () => void
Expand Down Expand Up @@ -87,22 +88,29 @@ function ConnectionForm({ onClose }: ConnectionFormProps) {
awsReplicationGroupId: connectionDetails.awsReplicationGroupId?.trim(),
}

const detailsToDispatch = connectionDetails.password
? { ...trimmed, password: await secureStorage.encryptIfAvailable(connectionDetails.password) }
: trimmed
let isPasswordEncrypted: boolean | undefined
let detailsToDispatch = trimmed
if (connectionDetails.password) {
const result = await secureStorage.encryptForStorage(connectionDetails.password)
isPasswordEncrypted = result.ok
// On failure keep the plaintext so this session can connect; it will not be
// persisted (see the persistence layer), and the user is warned.
detailsToDispatch = { ...trimmed, password: result.ok ? result.value : connectionDetails.password }
if (!result.ok) toast.warning(PASSWORD_NOT_STORED_WARNING)
}

if (trimmed.endpointType === "cluster-endpoint") {
const newDiscoveryId = `discovery-${buildConnectionId(trimmed.host, trimmed.port, 0)}`
setDiscoveryId(newDiscoveryId)
setConnectionId(null)
dispatch(discoveryEndpointPending({ discoveryId: newDiscoveryId, connectionDetails: detailsToDispatch }))
dispatch(discoveryEndpointPending({ discoveryId: newDiscoveryId, connectionDetails: detailsToDispatch, isPasswordEncrypted }))
return
}

const newConnectionId = buildConnectionId(trimmed.host, trimmed.port, trimmed.db)
setConnectionId(newConnectionId)
setDiscoveryId(null)
dispatch(connectPending({ connectionId: newConnectionId, connectionDetails: detailsToDispatch }))
dispatch(connectPending({ connectionId: newConnectionId, connectionDetails: detailsToDispatch, isPasswordEncrypted }))
}

return (
Expand Down
17 changes: 13 additions & 4 deletions apps/frontend/src/components/ui/edit-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { type FormEvent, useState, useEffect, useCallback } from "react"
import { useSelector } from "react-redux"
import { buildConnectionId, isValidDatabaseIndex } from "@common/src/connection-id.ts"
import { CONNECTED } from "@common/src/constants"
import { toast } from "sonner"
import { ConnectionModal } from "./connection-modal.tsx"
import {
updateConnectionDetails,
Expand All @@ -16,7 +17,7 @@ import {
selectIsAtConnectionLimit
} from "@/state/valkey-features/connection/connectionSelectors"
import { useAppDispatch } from "@/hooks/hooks"
import { secureStorage } from "@/utils/secureStorage.ts"
import { secureStorage, PASSWORD_NOT_STORED_WARNING } from "@/utils/secureStorage.ts"

interface EditFormProps {
onClose: () => void
Expand Down Expand Up @@ -146,15 +147,23 @@ function EditForm({ onClose, connectionId }: EditFormProps) {
dispatch(deleteConnection({ connectionId, silent: true }))

// Encrypt password only if user typed a new one; otherwise it's already encrypted from Redux
const detailsToDispatch = passwordChanged && connectionDetails.password
? { ...trimmed, password: await secureStorage.encryptIfAvailable(connectionDetails.password) }
: trimmed
// and carries the source connection's marking (the connect below targets a new
// connectionId, so the reducer can't inherit it).
let isPasswordEncrypted = passwordChanged ? undefined : fullConnection?.isPasswordEncrypted
let detailsToDispatch = trimmed
if (passwordChanged && connectionDetails.password) {
const result = await secureStorage.encryptForStorage(connectionDetails.password)
isPasswordEncrypted = result.ok
detailsToDispatch = { ...trimmed, password: result.ok ? result.value : connectionDetails.password }
if (!result.ok) toast.warning(PASSWORD_NOT_STORED_WARNING)
}

dispatch(
connectPending({
connectionId: newConnectionId,
connectionDetails: detailsToDispatch,
isEdit: true,
isPasswordEncrypted,
preservedHistory: connectionHistory,
}),
)
Expand Down
58 changes: 58 additions & 0 deletions apps/frontend/src/state/epics/valkeyEpics.persist.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { LOCAL_STORAGE } from "@common/src/constants"
import { persistConnections } from "./valkeyEpics"
import type { ConnectionState } from "@/state/valkey-features/connection/connectionSlice"

// persistConnections is the single write path to localStorage. Its security job:
// never write a password that could not be encrypted (isPasswordEncrypted === false).
describe("persistConnections", () => {
beforeEach(() => localStorage.clear())

const read = () => JSON.parse(localStorage.getItem(LOCAL_STORAGE.VALKEY_CONNECTIONS) ?? "{}")

const conn = (overrides: Partial<ConnectionState>): ConnectionState => ({
status: "NOT_CONNECTED" as ConnectionState["status"],
errorMessage: null,
searchableText: "",
connectionDetails: {
host: "h", port: "6379", tls: false, verifyTlsCertificate: false, endpointType: "node", db: 0,
password: "secret",
},
...overrides,
})

it("strips the password and drops the flag when the password is unencrypted", () => {
persistConnections({ a: conn({ isPasswordEncrypted: false }) })

const stored = read().a
expect(stored.connectionDetails.password).toBeUndefined()
expect("isPasswordEncrypted" in stored).toBe(false)
})

it("preserves an encrypted password (flag true)", () => {
persistConnections({ a: conn({ isPasswordEncrypted: true, connectionDetails: {
host: "h", port: "6379", tls: false, verifyTlsCertificate: false, endpointType: "node", db: 0,
password: "ciphertext",
} }) })

expect(read().a.connectionDetails.password).toBe("ciphertext")
})

it("preserves a password when the flag is absent (existing/normal connections)", () => {
persistConnections({ a: conn({}) })
expect(read().a.connectionDetails.password).toBe("secret")
})

it("only strips the flagged connection, leaving others intact", () => {
persistConnections({
bad: conn({ isPasswordEncrypted: false }),
good: conn({ isPasswordEncrypted: true, connectionDetails: {
host: "g", port: "6379", tls: false, verifyTlsCertificate: false, endpointType: "node", db: 0,
password: "keepme",
} }),
})

const stored = read()
expect(stored.bad.connectionDetails.password).toBeUndefined()
expect(stored.good.connectionDetails.password).toBe("keepme")
})
})
27 changes: 24 additions & 3 deletions apps/frontend/src/state/epics/valkeyEpics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,24 @@ const getCurrentConnections = () => R.pipe(
(s) => (s === null ? {} : JSON.parse(s)),
)(LOCAL_STORAGE.VALKEY_CONNECTIONS)

// Single write path to localStorage. A password that could not be encrypted
// (isPasswordEncrypted === false) is never written to disk: it is dropped to
// undefined so the connection re-prompts on next use, and the transient flag is
// not persisted either. All persistence writes must go through this.
export const persistConnections = (connections: Record<string, ConnectionState>) => {
const safe = Object.fromEntries(
Object.entries(connections).map(([id, conn]) => {
if (conn?.isPasswordEncrypted === false) {
const stripped = { ...conn, connectionDetails: { ...conn.connectionDetails, password: undefined } }
delete stripped.isPasswordEncrypted
return [id, stripped]
}
return [id, conn]
}),
)
localStorage.setItem(LOCAL_STORAGE.VALKEY_CONNECTIONS, JSON.stringify(safe))
}

export const connectionEpic = (store: Store) =>
merge(
action$.pipe(
Expand Down Expand Up @@ -97,10 +115,11 @@ export const connectionEpic = (store: Store) =>
status: NOT_CONNECTED,
connectionHistory: connection?.connectionHistory ?? [],
searchableText: connection?.searchableText ?? "",
isPasswordEncrypted: connection?.isPasswordEncrypted,
}

currentConnections[payload.connectionId] = connectionToSave
localStorage.setItem(LOCAL_STORAGE.VALKEY_CONNECTIONS, JSON.stringify(currentConnections))
persistConnections(currentConnections)

if (baseConnectionDetails?.host?.includes(".serverless.")) {
toast.warning(
Expand Down Expand Up @@ -156,6 +175,7 @@ export const connectionEpic = (store: Store) =>
port: String(firstNode.port),
endpointType: "node",
},
isPasswordEncrypted: discovery.isPasswordEncrypted,
}))
// store the connectionId in the discovery state so we can show the correct connection status
store.dispatch(discoveryNodeConnecting({ discoveryId, connectionId }))
Expand Down Expand Up @@ -372,7 +392,7 @@ export const deleteConnectionEpic = () =>
const currentConnections = getCurrentConnections()
if (currentConnections[connectionId]) {
currentConnections[connectionId].userDisconnected = true
localStorage.setItem(LOCAL_STORAGE.VALKEY_CONNECTIONS, JSON.stringify(currentConnections))
persistConnections(currentConnections)
}
} catch (e) {
console.error(e)
Expand Down Expand Up @@ -415,7 +435,8 @@ export const updateConnectionDetailsEpic = (store: Store) =>
currentConnections[connectionId].connectionDetails = connection.connectionDetails
currentConnections[connectionId].connectionHistory = connection.connectionHistory || []
currentConnections[connectionId].searchableText = connection.searchableText ?? ""
localStorage.setItem(LOCAL_STORAGE.VALKEY_CONNECTIONS, JSON.stringify(currentConnections))
currentConnections[connectionId].isPasswordEncrypted = connection.isPasswordEncrypted
persistConnections(currentConnections)
}
} catch (e) {
console.error(e)
Expand Down
Loading
Loading