Skip to content

feat(ui-react): add Secure Vault for encrypted SSH private key storage#5903

Open
luizhf42 wants to merge 14 commits intomasterfrom
feat/ui-react/secure-vault
Open

feat(ui-react): add Secure Vault for encrypted SSH private key storage#5903
luizhf42 wants to merge 14 commits intomasterfrom
feat/ui-react/secure-vault

Conversation

@luizhf42
Copy link
Member

@luizhf42 luizhf42 commented Feb 26, 2026

What

Encrypted SSH private key storage in the browser, protected by a master password. Keys are encrypted with AES-256-GCM (PBKDF2-SHA256 derived key) and persisted in localStorage. Private key authentication uses SSH challenge-response signing — the raw key never leaves the browser.

Why

ShellHub previously stored private keys in plain text in localStorage. The Secure Vault encrypts keys at rest behind a master password, matching the security model of tools like Termius Vault. This is the community/self-hosted implementation; the backend adapter pattern (IVaultBackend) supports future server-side storage for cloud/enterprise.

Changes

  • Crypto layer (vault-crypto.ts): AES-256-GCM encryption/decryption, PBKDF2-SHA256 key derivation (600k iterations), verifier-based password checking without storing the password hash. Session key held in module scope (not in React state or localStorage).
  • Storage abstraction (vault-backend.ts, vault-backend-local.ts): IVaultBackend interface with localStorage implementation and singleton factory.
  • SSH key utilities (ssh-keys.ts): sshpk-based key validation with KeyEncryptedError detection for auto-detecting passphrase-protected keys, MD5 fingerprint extraction, and challenge-response signature generation (RSA via node-rsa pkcs1-sha1, ED25519/ECDSA via sshpk sha512).
  • Vault store (vaultStore.ts): Zustand store managing vault lifecycle (uninitialized/locked/unlocked), key CRUD with duplicate name/data prevention, master password change, vault reset, and legacy key migration from the Vue UI format.
  • Vault UI components: Setup dialog, unlock dialog (with autocomplete suppression), locked banner, settings section (change password, lock, reset with typed confirmation).
  • Secure Vault page: Three-state page — onboarding for uninitialized, locked banner, and full key table with search, add/edit drawer (auto-detects encrypted keys and prompts for passphrase), and delete confirmation.
  • Terminal integration: TerminalInstance now implements the WebSocket challenge-response protocol (kind 3 Signature) for private key auth — POST sends fingerprint only, browser signs challenges locally. Sensitive key material is cleared immediately after signing and on component unmount. ConnectDrawer adds vault key selector with vault/manual toggle and inline unlock prompt.
  • Auth integration: Vault session key cleared on logout (explicit, token expiry, 401).
  • Reusable key file input: Extracted shared file input logic (drag-and-drop, paste interception, file/text mode toggle) into useKeyFileInput hook and KeyFileInput component, used by both public keys and secure vault drawers.
  • Dependencies: Added sshpk, node-rsa, vite-plugin-node-polyfills (provides Node.js globals for sshpk/node-rsa in browser).

Testing

  • Set up a vault, add keys (with and without passphrase), verify encrypted storage in localStorage (shellhub-vault-meta, shellhub-vault-data — no plaintext).
  • Connect to a device with private key auth via vault selector and manual input — verify challenge-response completes (no kind 4 error).
  • Test RSA, ED25519, and ECDSA key types for both vault storage and terminal connection.
  • Change master password, then verify old password fails and new password unlocks with all keys intact.
  • Reset vault, verify return to onboarding state.
  • Logout and verify vault locks (session key cleared).
  • If privateKeys exists in localStorage (legacy Vue UI), verify migration on first vault setup.
  • 311 unit/component tests covering store, crypto utils, SSH key utils, backend, hook, and UI components.

@luizhf42 luizhf42 force-pushed the feat/ui-react/secure-vault branch 6 times, most recently from 06e4ad4 to ab6b7e7 Compare March 2, 2026 14:22
@luizhf42 luizhf42 marked this pull request as ready for review March 2, 2026 14:29
@luizhf42 luizhf42 requested a review from a team as a code owner March 2, 2026 14:29
@luizhf42 luizhf42 self-assigned this Mar 2, 2026
@luizhf42 luizhf42 force-pushed the feat/ui-react/secure-vault branch from ab6b7e7 to 78bbe9f Compare March 2, 2026 14:31
@gustavosbarreto
Copy link
Member

/review

@github-actions
Copy link

github-actions bot commented Mar 2, 2026

Claude finished @gustavosbarreto's task in 8m 48s —— View job


Code Review Complete

Reviewed 39 files across code quality, security, testing, TypeScript/React patterns, and architecture. 14 inline issues posted (1 high, 9 medium, 4 low).

Highlights

Security — The crypto implementation is sound: AES-256-GCM with PBKDF2-SHA256 at 600k iterations, fresh random IVs, session key in module scope (not React state or localStorage), vault locked on logout. Challenge-response SSH signing correctly keeps the private key client-side. One gap: clearSensitiveData is missing in the signature failure path (see comment).

Architecture — The IVaultBackend interface is synchronous, which will require a breaking refactor when adding the cloud/enterprise server-side backend (see comment). Vault storage keys aren't namespaced per user (see comment).

Performance — Six components use useVaultStore() without selectors, causing unnecessary re-renders cascading from the page root (see comment).

Additional notes

Testing gaps — 311 tests cover the store, crypto, SSH utils, backend, hook, and some UI components well. However, four vault UI components have zero test coverage: VaultSetupDialog, VaultUnlockDialog, VaultSettingsSection (including ChangePasswordDrawer), and ConnectDrawer. These are security-sensitive components handling master password entry, vault initialization, password changes, and SSH key authentication. Also, TerminalInstance's new challenge-response signing flow has no tests.

Token expiry — The logout action correctly locks the vault, but when the session expires via API interceptor redirect (without a full page reload), the vault session key may remain in memory. The interceptor should also call useVaultStore.getState().lock().


If you've addressed the feedback and want a new review, tag @shellhub-io/admin and a team member can trigger it.

Add sshpk and node-rsa for SSH private key parsing, fingerprint
extraction, and challenge-response signing. Add
vite-plugin-node-polyfills to provide Node.js globals (process, Buffer)
required by these libraries in the browser environment.
@luizhf42 luizhf42 force-pushed the feat/ui-react/secure-vault branch from ff5cfac to 9a318ab Compare March 2, 2026 19:52
@gustavosbarreto
Copy link
Member

/review

@github-actions
Copy link

github-actions bot commented Mar 2, 2026

Claude finished @gustavosbarreto's task in 3m 11s —— View job


Code Review Complete

Reviewed 39 files across code quality, security, testing, TypeScript/React patterns, and architecture. 6 inline issues posted (0 high, 4 medium, 2 low).

All fixes from the previous two review rounds were verified as correctly applied.

Highlights

Security — PBKDF2 iterations read from localStorage is passed to deriveKey without bounds-checking. An attacker with localStorage write access could set iterations to 1, silently degrading key derivation strength (see comment). Separately, the TerminalInstance WebSocket closure retains the session prop (including privateKey/passphrase) for the entire connection lifetime even after clearSensitiveData updates the store — the closure holds the original reference (see comment).

Type Safety — Vault data shape validation checks id, name, data, fingerprint but misses hasPassphrase (boolean). Corrupted or tampered localStorage data passes validation and gets cast to VaultKeyEntry[], potentially causing the passphrase prompt to incorrectly show or hide (see comment).

UX — Three vault dialogs (VaultUnlockDialog, VaultSetupDialog, ChangePasswordDrawer) read the shared error from vaultStore but none clear it on reopen. A stale error from a previous attempt is shown alongside empty inputs (see comment).

Additional notes

Testing gaps — Same 5 components from prior rounds still have zero test coverage: VaultSetupDialog, VaultUnlockDialog, VaultSettingsSection/ChangePasswordDrawer, ConnectDrawer, and TerminalInstance's challenge-response flow. Additionally, the vault data corruption paths (non-array decrypt result, items with missing fields) and the changeMasterPassword rollback (saveMeta with old meta) are not exercised in the store tests.

No cross-repo impact — The cloud/ repo contains only Go backend code and does not reference any files changed in this PR.


If you've addressed the feedback and want a new review, tag @shellhub-io/admin and a team member can trigger it.

Comment on lines +18 to +20
useEffect(() => {
if (open) setPassword("");
}, [open]);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium · UX: Stale store error shown on reopen. The vault store's error field is shared across VaultUnlockDialog, VaultSetupDialog, and ChangePasswordDrawer, but none of them clear it when the dialog opens. If the user triggers an error (e.g., wrong password), closes the dialog, and reopens it, the old error message is displayed alongside empty inputs.

Suggested change
useEffect(() => {
if (open) setPassword("");
}, [open]);
useEffect(() => {
if (open) {
setPassword("");
useVaultStore.setState({ error: null });
}
}, [open]);

Same issue also at:

  • ui-react/apps/admin/src/components/vault/VaultSetupDialog.tsx:24-32
  • ui-react/apps/admin/src/components/vault/VaultSettingsSection.tsx:27-33 (ChangePasswordDrawer)

Comment on lines +157 to +165
const isValid = parsed.every(
(item: unknown) =>
typeof item === "object" &&
item !== null &&
typeof (item as Record<string, unknown>).id === "string" &&
typeof (item as Record<string, unknown>).name === "string" &&
typeof (item as Record<string, unknown>).data === "string" &&
typeof (item as Record<string, unknown>).fingerprint === "string",
);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium · type safety: Vault data validation is missing the hasPassphrase field. VaultKeyEntry requires hasPassphrase: boolean, but the shape check only validates id, name, data, and fingerprint as strings. If stored data has a missing or non-boolean hasPassphrase, the cast on line 167 produces objects that violate the type contract — the passphrase prompt in ConnectDrawer could incorrectly show or hide.

Suggested change
const isValid = parsed.every(
(item: unknown) =>
typeof item === "object" &&
item !== null &&
typeof (item as Record<string, unknown>).id === "string" &&
typeof (item as Record<string, unknown>).name === "string" &&
typeof (item as Record<string, unknown>).data === "string" &&
typeof (item as Record<string, unknown>).fingerprint === "string",
);
const isValid = parsed.every(
(item: unknown) =>
typeof item === "object" &&
item !== null &&
typeof (item as Record<string, unknown>).id === "string" &&
typeof (item as Record<string, unknown>).name === "string" &&
typeof (item as Record<string, unknown>).data === "string" &&
typeof (item as Record<string, unknown>).fingerprint === "string" &&
typeof (item as Record<string, unknown>).hasPassphrase === "boolean",
);

Comment on lines +49 to +53
export async function deriveKey(
password: string,
salt: Uint8Array,
iterations: number = PBKDF2_ITERATIONS,
): Promise<CryptoKey> {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium · security: deriveKey accepts any iterations value without validation. Since verifyPassword passes meta.iterations from localStorage (untrusted), an attacker with localStorage write access (e.g., XSS, browser extension) could set iterations to 1, making the derived key trivially brute-forceable against the known verifier ciphertext. Adding a minimum bound prevents silent key-strength degradation.

Suggested change
export async function deriveKey(
password: string,
salt: Uint8Array,
iterations: number = PBKDF2_ITERATIONS,
): Promise<CryptoKey> {
export async function deriveKey(
password: string,
salt: Uint8Array,
iterations: number = PBKDF2_ITERATIONS,
): Promise<CryptoKey> {
if (!Number.isInteger(iterations) || iterations < 100_000) {
throw new Error("PBKDF2 iteration count is too low or invalid");
}

Comment on lines +152 to +170
case WS_KIND.SIGNATURE: {
if (!session.privateKey) return;
const challengeBuffer = Buffer.from(msg.data, "base64");
try {
const signature = generateSignature(
session.privateKey,
challengeBuffer,
session.passphrase,
);
ws.send(JSON.stringify({ kind: WS_KIND.SIGNATURE, data: signature }));
} catch {
term.write("\r\n\x1b[1;31mFailed to sign authentication challenge.\x1b[0m\r\n");
useTerminalStore.getState().clearSensitiveData(session.id);
ws.close();
return;
}
// Clear sensitive key material after successful handshake
useTerminalStore.getState().clearSensitiveData(session.id);
registerResizeHandler();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium · security: The ws.onmessage closure captures the session prop object from effect creation time (effect depends only on [session.id]). When clearSensitiveData updates the store, the original session object in this closure still holds privateKey and passphrase for the entire WebSocket lifetime. The store state is cleared, but the closure retains the original reference — so the private key stays reachable in memory until the component unmounts.

Consider reading the key material from the store inside the handler rather than from the closure:

case WS_KIND.SIGNATURE: {
  const { privateKey, passphrase } = useTerminalStore.getState()
    .sessions.find(s => s.id === session.id) ?? {};
  if (!privateKey) return;
  // ... sign and clear
}

This way, clearSensitiveData at line 169 actually removes the last reference to the key material.

Comment on lines +231 to +237
<ConfirmDialog
open={resetOpen}
onClose={() => setResetOpen(false)}
onConfirm={async () => {
await resetVault();
setResetOpen(false);
}}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

low · UX: resetConfirmText is only cleared when the "Reset Vault" button is clicked (line 208). If the user opens the dialog, partially types (e.g., "RES"), closes via Cancel or backdrop, and reopens, the stale text persists.

Suggested change
<ConfirmDialog
open={resetOpen}
onClose={() => setResetOpen(false)}
onConfirm={async () => {
await resetVault();
setResetOpen(false);
}}
<ConfirmDialog
open={resetOpen}
onClose={() => {
setResetConfirmText("");
setResetOpen(false);
}}
onConfirm={() => {
resetVault();
setResetOpen(false);
}}

Comment on lines +56 to +59
if (file.size > 512 * 1024) {
setFileSizeError(true);
return;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

low · robustness: When a file exceeds the size limit, fileSizeError is set to true but fileReadError is not cleared. If a user previously encountered a file read error and then drops an oversized file, both error messages are displayed simultaneously.

Suggested change
if (file.size > 512 * 1024) {
setFileSizeError(true);
return;
}
if (file.size > 512 * 1024) {
setFileSizeError(true);
setFileReadError(false);
return;
}

luizhf42 added 11 commits March 2, 2026 18:20
…tation

Define IVaultBackend interface for storage abstraction, implement
LocalVaultBackend using localStorage for community/self-hosted edition,
and add singleton factory for backend instantiation. The adapter pattern
allows future server-side storage without changing vault logic.
Add private key validation with encrypted key detection
(KeyEncryptedError), MD5 fingerprint extraction, and challenge-response
signature generation using sshpk and node-rsa. Supports RSA (pkcs1-sha1)
and ED25519/ECDSA (sha512) key types.
Implement vault state management with full lifecycle: initialize, unlock,
lock, and key CRUD operations (add, update, remove). Includes duplicate
key prevention (name and data uniqueness), master password change, vault
reset, and legacy key migration from the old Vue UI format.
Add VaultSetupDialog for first-time master password creation,
VaultUnlockDialog for vault unlock with autocomplete suppression,
VaultLockedBanner for locked state indication, and VaultSettingsSection
with master password change, lock, and reset vault functionality.
Add the main Secure Vault page with three states: uninitialized
(onboarding), locked (banner), and unlocked (key table with search).
Includes KeyDrawer for add/edit with automatic encrypted key detection
and passphrase validation, KeyDeleteDialog for key removal, and
duplicate key prevention with per-field error messages.
Clear the vault session key from memory when the user logs out,
covering explicit logout, token expiration, and 401 responses.
Extract the duplicated file-input logic (FileReader, 512KB limit, drag-and-drop,
paste interception, file/text mode toggle) into useKeyFileInput hook and
KeyFileInput component. Refactor public-keys KeyDataInput to use them.
Replace the inline file/text input block with the shared KeyFileInput
component, removing duplicated file-input logic.
Cover vault crypto utilities, localStorage backend, SSH key
utils, vault Zustand store, KeyFileInput hook and component,
and secure-vault page components.
@luizhf42
Copy link
Member Author

luizhf42 commented Mar 2, 2026

All 4 items from round 3 addressed:

  • vaultStore.ts hasPassphrase validation — f92a8f73d
  • useKeyFileInput.ts ClipboardEvent typing — 5d0de4cfd
  • VaultSetupDialog/VaultUnlockDialog stale error — 88941fd69
  • vault-backend-local.ts loadMeta runtime shape validation — abf39cf42

@luizhf42 luizhf42 force-pushed the feat/ui-react/secure-vault branch from 9a318ab to a0c6f5e Compare March 2, 2026 21:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants