Skip to content
Merged
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
22 changes: 19 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,24 @@
# Passman

A zero-knowledge password manager. Vault data is encrypted on the client with
a key derived from your master password, and the server only ever stores
ciphertext + KDF parameters. A database breach leaks nothing usable.
A zero-knowledge password manager built for DBAs and infrastructure teams.
Vault data is encrypted on the client with a key derived from your master
password, and the server only ever stores ciphertext + KDF parameters. A
database breach leaks nothing usable.

![Passman vault dashboard](docs/img/vault.png)

The vault treats credentials as connection targets, not just `name +
password` rows: every entry carries protocol, hostname, IP, port, and
optional service-name / Windows-domain / database fields. One click on
**Connect →** turns a saved credential into a working session — a JDBC
URL for DBeaver / DataGrip / DBVisualizer, a launched SSH terminal, a
ready-to-paste `psql` / `mysql` / `sqlplus` command, or a downloadable
`.rdp` file. The password lands on the clipboard with a 30-second
auto-clear; the server still sees only ciphertext.

![Connect dialog with JDBC, SSH, copy-command, and RDP options](docs/img/vault-connect.png)

## Architecture at a glance

```
┌─────────────────┐ auth_key (one-way) ┌───────────────────┐
Expand Down
6 changes: 4 additions & 2 deletions packages/web/src/connect/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
export {
buildTargetSubtitle,
canBuildRdp,
defaultPort,
effectiveProtocol,
inferProtocolFromPort,
engineCode,
inferProtocolFromPort,
protocolLabel,
defaultPort,
} from "./protocol.js";
export { buildJdbcUrl, supportsJdbc } from "./jdbc.js";
export { buildConnectCommand } from "./command.js";
Expand Down
36 changes: 36 additions & 0 deletions packages/web/src/connect/protocol.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { Protocol, VaultLoginPlaintext } from "@passman/core";



/**
* Map a port number to its most likely protocol. Used as a fallback when a
* credential predates the protocol field, or for sane defaults in the Add
Expand Down Expand Up @@ -77,3 +79,37 @@ export function defaultPort(protocol: Protocol | undefined): number | undefined
default: return undefined;
}
}

/**
* Build the dialog "where this points" subtitle. Concatenates the
* canonical host:port, then any non-redundant context (a separate IP if
* the hostname is the primary identifier, the username with optional AD
* domain).
*/
export function buildTargetSubtitle(item: VaultLoginPlaintext): string {
const host = item.hostname || item.ip;
const hostPort = host
? item.port !== undefined ? `${host}:${item.port}` : host
: "";
const userLabel = item.username
? item.domain ? `${item.domain}\\${item.username}` : item.username
: "";
// Show the IP as a separate segment only when the hostname was used as
// the primary identifier — otherwise we'd repeat it.
const extraIp = item.hostname && item.ip && item.ip !== item.hostname
? item.ip
: "";
return [hostPort, extraIp, userLabel].filter(Boolean).join(" · ");
}

/**
* Whether a credential's Connect dialog should enable the "Open RDP session"
* action. Limited to credentials whose effective protocol is RDP — for any
* other protocol the credential's port is for a different service and the
* generated .rdp file would point at the wrong port (e.g. 5432 for a
* Postgres credential).
*/
export function canBuildRdp(item: VaultLoginPlaintext): boolean {
if (effectiveProtocol(item) !== "rdp") return false;
return Boolean(item.hostname || item.ip);
}
20 changes: 15 additions & 5 deletions packages/web/src/connect/ssh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,21 @@ export function buildSshUrl(item: VaultLoginPlaintext): string | null {
/**
* Trigger the OS's `ssh://` handler. Some browsers gate this behind a user
* prompt the first time — that's fine, that prompt only fires once per
* origin/scheme. We use `location.href` (rather than a `<a target="_blank">`)
* so the navigation stays in the existing tab.
* origin/scheme.
*
* Implementation notes: assigning `location.href` to a custom-scheme URL is
* unreliable in Chromium — if no handler is registered, Chrome navigates the
* tab to an "ERR_UNKNOWN_URL_SCHEME" error page, throwing the user out of
* the app. The synthetic-anchor-click pattern below is what 1Password and
* Bitwarden use: the click handler invokes the protocol handler if present
* and is a silent no-op otherwise.
*/
export function launchSshUrl(url: string): void {
// Assigning to location.href triggers the protocol handler without
// creating an extra history entry that the user would have to back out of.
window.location.href = url;
const a = document.createElement("a");
a.href = url;
a.rel = "noopener";
a.style.display = "none";
document.body.appendChild(a);
a.click();
a.remove();
}
17 changes: 15 additions & 2 deletions packages/web/src/pages/VaultPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -191,8 +191,21 @@ export function VaultPage() {

function toggleSelectAll() {
setSelected((s) => {
if (s.size === filtered.length) return new Set();
return new Set(filtered.map((it) => it.id));
// "Are all *visible* rows currently selected?" — not just a size match.
// A size match can be coincidental (e.g. you selected items in another
// scope, then narrowed the view). The CredentialsGrid component's
// header-checkbox visual uses the same `every`-based check, so the
// toggle and the indicator stay in sync.
const allVisibleSelected =
filtered.length > 0 && filtered.every((it) => s.has(it.id));
if (allVisibleSelected) {
const next = new Set(s);
for (const it of filtered) next.delete(it.id);
return next;
}
const next = new Set(s);
for (const it of filtered) next.add(it.id);
return next;
});
}

Expand Down
51 changes: 25 additions & 26 deletions packages/web/src/pages/vault/ConnectDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import { useEffect } from "react";
import {
buildConnectCommand,
buildJdbcUrl,
buildRdpFile,
buildSshUrl,
buildTargetSubtitle,
canBuildRdp,
copyPlain,
copySensitive,
downloadRdpFile,
Expand Down Expand Up @@ -42,20 +43,16 @@ export function ConnectDialog({ item, onClose, onUsed, onToast }: Props) {
const code = engineCode(protocol);
const proto = protocolLabel(protocol);

const target = [
p.hostname,
p.ip,
p.port !== undefined ? `${p.hostname || p.ip || ""}:${p.port}` : "",
p.username && (p.domain ? `${p.domain}\\${p.username}` : p.username),
]
.filter(Boolean)
.filter((v, i, arr) => arr.indexOf(v) === i)
.join(" · ");
const target = buildTargetSubtitle(p);

const jdbcUrl = supportsJdbc(protocol) ? buildJdbcUrl(p) : null;
const sshUrl = buildSshUrl(p);
const cmd = buildConnectCommand(p);
const canRdp = !!buildRdpFile({ ...p, protocol: "rdp" });
// RDP is offered only when the credential is itself an RDP entry. For a
// Postgres credential on port 5432 we'd otherwise generate an .rdp file
// pointing at port 5432, which doesn't run RDP — that's a bug, not a
// feature.
const canRdp = canBuildRdp(p);

function done(action: string) {
onUsed(item!.id);
Expand All @@ -66,35 +63,35 @@ export function ConnectDialog({ item, onClose, onUsed, onToast }: Props) {
async function handleJdbc() {
if (!jdbcUrl) return;
await copyPlain(jdbcUrl);
if (p.password) {
// Queue the password on the clipboard with auto-clear so the user can
// paste it into the next field. The plain URL was just overwritten by
// copySensitive's writeText, but that's the desired ordering — the
// user pastes the URL first into DBeaver, then "Copy" again on the
// next field grabs the password (we explicitly switched). For now we
// give them the URL only and let them re-trigger via Copy buttons.
}
done(`JDBC URL copied · ${jdbcUrl}`);
done("JDBC URL copied · use the row's Copy button for the password");
}

async function handleSsh() {
if (!sshUrl) return;
// Copy the password first (so it's on the clipboard when the SSH client
// prompts for it), then launch the URL handler. If we launched first,
// the synthetic anchor click could race with the writeText call in
// browsers that suspend the page on protocol-handler invocation.
if (p.password) await copySensitive(p.password);
launchSshUrl(sshUrl);
done("Launching SSH · password on clipboard, clears in 30 s");
}

async function handleCommand() {
if (!cmd) return;
// Copy the command alone. We deliberately do NOT also copy the password
// here — the second writeText would overwrite the command, leaving the
// user's clipboard holding the password they expected to paste a command
// from. The row's per-row Copy button is the password path.
await copyPlain(cmd);
if (p.password) await copySensitive(p.password);
done("Connect command copied · password on clipboard");
done("Connect command copied · use the row's Copy button for the password");
}

async function handleRdp() {
const ok = downloadRdpFile({ ...p, protocol: "rdp" });
if (!canRdp) return;
const ok = downloadRdpFile(p);
if (!ok) {
onToast("Add hostname + RDP port to enable RDP");
onToast("Add hostname or IP to this credential");
return;
}
if (p.password) await copySensitive(p.password);
Expand Down Expand Up @@ -147,7 +144,7 @@ export function ConnectDialog({ item, onClose, onUsed, onToast }: Props) {
icon={<IconCopy />}
title="Copy connect command"
meta={cmd ?? "No canonical command for this protocol"}
hint="Ready-to-paste shell command · password also on clipboard"
hint="Ready-to-paste shell command · use the row's Copy button for the password"
cta="Copy"
onClick={handleCommand}
/>
Expand All @@ -159,7 +156,9 @@ export function ConnectDialog({ item, onClose, onUsed, onToast }: Props) {
meta={
canRdp
? `${p.hostname || p.ip}:${p.port ?? 3389}`
: "Add hostname or IP to enable"
: protocol === "rdp"
? "Add hostname or IP to enable"
: "Set protocol to RDP on this credential to enable"
}
hint="Downloads a pre-filled .rdp · password copied to clipboard, paste at the credential prompt"
cta="Download .rdp"
Expand Down
103 changes: 103 additions & 0 deletions packages/web/tests/connect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { describe, expect, it } from "vitest";
import { buildConnectCommand } from "../src/connect/command.js";
import { buildJdbcUrl, supportsJdbc } from "../src/connect/jdbc.js";
import {
buildTargetSubtitle,
canBuildRdp,
defaultPort,
effectiveProtocol,
engineCode,
Expand Down Expand Up @@ -351,3 +353,104 @@ describe("buildRdpFile", () => {
expect(buildRdpFile({ ...baseItem, protocol: "rdp" })).toBeNull();
});
});

describe("buildTargetSubtitle (Connect dialog header subtitle)", () => {
it("renders host:port + user without duplicating the host", () => {
expect(
buildTargetSubtitle({
...baseItem,
hostname: "db-prod-01",
ip: "10.0.0.42",
port: 5432,
username: "postgres",
}),
).toBe("db-prod-01:5432 · 10.0.0.42 · postgres");
});

it("collapses ip when it equals the hostname", () => {
expect(
buildTargetSubtitle({
...baseItem,
hostname: "10.0.0.42",
ip: "10.0.0.42",
port: 5432,
}),
).toBe("10.0.0.42:5432 · alice");
});

it("falls back to ip when hostname is missing", () => {
expect(
buildTargetSubtitle({
...baseItem,
ip: "10.0.0.42",
port: 5432,
}),
).toBe("10.0.0.42:5432 · alice");
});

it("encodes Windows AD domain into the user segment for RDP entries", () => {
expect(
buildTargetSubtitle({
...baseItem,
protocol: "rdp",
hostname: "host",
port: 3389,
username: "admin",
domain: "EXAMPLE",
}),
).toBe("host:3389 · EXAMPLE\\admin");
});

it("omits port when unset", () => {
expect(
buildTargetSubtitle({ ...baseItem, hostname: "host", username: "alice" }),
).toBe("host · alice");
});

it("returns the empty string when no host or user", () => {
expect(buildTargetSubtitle({ name: "x", username: "", password: "" })).toBe("");
});
});

describe("canBuildRdp (gates the Open RDP session option)", () => {
it("is true for an RDP credential with a host", () => {
expect(
canBuildRdp({
...baseItem,
protocol: "rdp",
hostname: "erp-db-01",
port: 3389,
}),
).toBe(true);
});

it("is true when protocol is inferred from port 3389", () => {
// No explicit protocol — port 3389 should infer RDP.
expect(
canBuildRdp({ ...baseItem, hostname: "host", port: 3389 }),
).toBe(true);
});

it("is FALSE for a Postgres credential — its port isn't an RDP port", () => {
// This is the regression. Without this gate, canBuildRdp would yield
// an .rdp file pointing at port 5432, which doesn't run RDP.
expect(
canBuildRdp({
...baseItem,
protocol: "psql",
hostname: "db-prod-01",
port: 5432,
}),
).toBe(false);
});

it("is false for any non-RDP protocol with a host", () => {
expect(canBuildRdp({ ...baseItem, protocol: "ssh", hostname: "h" })).toBe(false);
expect(canBuildRdp({ ...baseItem, protocol: "redis", hostname: "h" })).toBe(false);
expect(canBuildRdp({ ...baseItem, protocol: "mongo", hostname: "h" })).toBe(false);
});

it("is false for an RDP credential without a host", () => {
expect(canBuildRdp({ ...baseItem, protocol: "rdp", port: 3389 })).toBe(false);
});
});
Loading