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
17 changes: 5 additions & 12 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -135,18 +135,11 @@ jobs:
- name: Build web
run: npm run build --workspace=@passman/web

# NOTE: @passman/extension's `build` script invokes `vite build`, but
# the package has no vite.config.ts. Vite falls back to SPA mode and
# fails with "Could not resolve entry module 'index.html'". Producing
# a loadable MV3 extension needs a multi-entry config (background,
# content, popup) plus a static-copy plugin for manifest.json. That
# is dedicated work tracked for a follow-up; until then, the
# extension's correctness is enforced by:
# - `tsc --noEmit` in the typecheck step above
# - the @crxjs/vite-plugin or equivalent migration (Phase 2)
# Skipping `npm run build --workspace=@passman/extension` here
# rather than masking it with `|| true` keeps the failure visible
# and the CI signal honest.
# The extension now ships with a Vite multi-entry config (background,
# content, popup) plus a manifest-copy plugin. Building here verifies
# the bundle Chrome would `Load unpacked` against doesn't regress.
- name: Build extension
run: npm run build --workspace=@passman/extension

- name: Test (all workspaces)
run: npm run test --workspaces --if-present
Expand Down
103 changes: 98 additions & 5 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,15 +268,108 @@ When ticked:
The toggle defaults to off, so existing zero-knowledge guarantees apply
to everyone who doesn't opt in.

### 5b. Generate a strong password

Click **Generate** next to the Password field to open the inline generator:

- **Length** slider (6–64). Default 20.
- Toggles for `a–z`, `A–Z`, `0–9`, symbols, and *No 0/O/1/l* (drops
visually-confusable characters — useful for passwords you'll read aloud).
- Live **strength meter** showing entropy bits and a label
(`weak` / `fair` / `strong` / `excellent`) — backed by Shannon entropy
(length × log₂(alphabet)).
- **Use this password** stamps the value into the Password field; **↻**
re-rolls without closing the popover.

The CSPRNG is the browser's `crypto.getRandomValues`. Rejection sampling
ensures every char in the alphabet has equal probability — `% length`
would skew toward the start of the alphabet for non-power-of-two charsets.

Your last-used options persist to localStorage so you don't re-tick the
same boxes every time.

### 5c. Import from another vault

Click **↥ Import** in the page header to open the import dialog. v1
supports **Bitwarden CSV** (the most common export format).

In Bitwarden: **Tools → Export vault → File format CSV**. The export
contains plaintext passwords — keep the file on this device, import it
in Passman, and delete it.

The dialog parses the file, shows you a preview of the importable rows
plus any rows it skipped (with reasons), and lets you confirm. There's
also a **Store all imported items on this device only** toggle if you
want the whole batch local-only.

What gets mapped: `name`, `login_username`, `login_password`,
`login_uri`, `notes`, `login_totp`. Folders and unknown columns are
silently dropped. Non-login items (notes, cards, identities) are
skipped — Passman only stores logins today.

### 5d. SSH private-key field

For SSH-protocol credentials, the form grows an **SSH private key
(PEM)** textarea below the URL field. Paste any of:

- `-----BEGIN OPENSSH PRIVATE KEY-----`
- `-----BEGIN RSA PRIVATE KEY-----`
- `-----BEGIN EC PRIVATE KEY-----`
- `-----BEGIN PRIVATE KEY-----`

The key is encrypted with the same vault key as the password — the
server never sees its contents. Once attached, the Connect dialog's
**Connect with SSH key** option becomes active: it downloads the key
as `<name>.pem` and copies a matching command to the clipboard:

```
ssh -i ~/.ssh/passman/<name>.pem user@host [-p port]
```

Move the downloaded `.pem` into `~/.ssh/passman/` (chmod `600`), paste
the command, and connect.

### 5e. Edit an existing credential

Hover any row → click the **✎** icon next to the delete button. The
form opens in Edit mode with all current fields pre-filled. The
storage-location toggle is hidden (you can't move items between stores
from the form — that's a separate operation we deliberately defer).
Click **Save changes** to re-encrypt and persist.

For local-only items, the update writes a new IndexedDB record. For
server items, it issues `PATCH /api/vault/items/:id`.

### 5f. Export your vault

Click **Export backup** in the user card at the bottom of the sidebar.
Passman builds a JSON file containing every encrypted blob (server +
local) plus metadata, then downloads it as
`passman-<vault>-<YYYYMMDD>.json`. The file is unreadable without your
master password — the vault key never leaves the browser.

Use this for: disaster-recovery snapshots, migration between
self-hosted instances, before clearing site data.

The file shape is intentionally stable (`{ format, version, vault,
items: [...] }`) so a future Restore flow can consume it without a
schema bump.

---

## 6. Chrome extension

> ⚠️ The extension's runtime code typechecks cleanly, but a loadable
> Chrome bundle requires a Vite multi-entry config that is tracked for a
> follow-up. The screenshots below show the popup as it will appear once
> the extension is loadable. The web vault remains the primary client
> for now.
The extension is now buildable and loadable. From the repo root:

```bash
npm install
npm run build --workspace=@passman/core # core types must build first
npm run build --workspace=@passman/extension # produces packages/extension/dist/
```

Then in Chrome / Edge / Brave: `chrome://extensions` → toggle
**Developer mode** → **Load unpacked** → pick
`packages/extension/dist/`. The Passman icon appears in your toolbar.

### 6a. Locked popup

Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@ export interface VaultLoginPlaintext {
domain?: string;
/** Free-form environment label shown as a row tag (e.g. "prod", "staging"). */
environment?: string;
/**
* SSH private key (PEM-encoded). When set the Connect dialog offers a
* "Use SSH key" action that downloads the key as `<name>.pem`. The blob
* is encrypted with the same vault key as the rest of the credential —
* the server never sees its contents.
*/
privateKey?: string;
notes?: string;
/** otpauth:// URI for TOTP, optional. */
totp?: string;
Expand Down
82 changes: 82 additions & 0 deletions packages/extension/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* Vite config that produces a Chrome-loadable Manifest V3 bundle.
*
* The MV3 platform has three independently-loaded JS contexts: the
* service worker (background), the content script, and the popup. They
* can't share a Rollup chunk graph the way a single SPA can, so we run
* Vite once with three separate `input` entries and force `inlineDynamicImports`.
*
* `manifest.json` and `popup/index.html` are copied verbatim from `src/`
* to `dist/` by the `copy-manifest` plugin below; the manifest's `js`
* fields then resolve against the flat `dist/` layout that
* `chrome://extensions → Load unpacked` expects.
*/

import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";

import { defineConfig, type Plugin } from "vite";

const __dirname = dirname(fileURLToPath(import.meta.url));
const SRC = resolve(__dirname, "src");
const OUT = resolve(__dirname, "dist");

/**
* Copy `manifest.json` to dist/ and rewrite the popup HTML so its script
* src points at `popup.js` (the bundled, no-extension form Vite emits).
*/
function copyExtensionAssets(): Plugin {
return {
name: "passman-extension-assets",
apply: "build",
closeBundle() {
if (!existsSync(OUT)) mkdirSync(OUT, { recursive: true });

// 1. manifest.json — straight copy, no transform.
copyFileSync(
resolve(__dirname, "manifest.json"),
resolve(OUT, "manifest.json"),
);

// 2. popup/index.html — rewrite the module script src to the bundled
// `popup.js` that Vite produced (which lives at the dist root).
const popupSrc = readFileSync(resolve(SRC, "popup/index.html"), "utf8");
const popupOut = popupSrc.replace(
/src="\.\/popup\.ts"/,
'src="./popup.js"',
);
writeFileSync(resolve(OUT, "popup.html"), popupOut);
},
};
}

export default defineConfig({
publicDir: false,
build: {
outDir: OUT,
emptyOutDir: true,
target: "esnext",
sourcemap: true,
minify: false,
// Chrome extension contexts can't share a runtime, so each entry is
// bundled independently with its dynamic imports inlined.
rollupOptions: {
input: {
background: resolve(SRC, "background.ts"),
content: resolve(SRC, "content.ts"),
popup: resolve(SRC, "popup/popup.ts"),
},
output: {
entryFileNames: "[name].js",
chunkFileNames: "[name].js",
assetFileNames: "[name][extname]",
format: "es",
inlineDynamicImports: false,
manualChunks: undefined,
},
preserveEntrySignatures: false,
},
},
plugins: [copyExtensionAssets()],
});
10 changes: 10 additions & 0 deletions packages/web/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,16 @@ export const api = {
body: JSON.stringify(body),
}, token),

updateItem: (
token: string,
id: string,
body: { item_type?: string; encrypted_data?: string },
) =>
request<VaultItem>(`/vault/items/${id}`, {
method: "PATCH",
body: JSON.stringify(body),
}, token),

deleteItem: (token: string, id: string) =>
request<void>(`/vault/items/${id}`, { method: "DELETE" }, token),
};
77 changes: 77 additions & 0 deletions packages/web/src/backup/export.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* Backup-export builder.
*
* The exported file is plain JSON containing **already-encrypted**
* ciphertext blobs — the same envelopes the server stores, plus the local
* IndexedDB items, plus enough metadata for a future Restore flow to know
* where each item belongs. The user's master password is required to
* decrypt; it is NOT in the file.
*
* Shape (`PassmanBackupV1`):
* {
* "format": "passman-backup",
* "version": 1,
* "exportedAt": ISO timestamp,
* "vault": "user@example.az",
* "items": [
* { id, item_type, encrypted_data, location: "server" | "local",
* created_at, updated_at }
* ]
* }
*/
import type { LocatedItem } from "../storage/index.js";

export interface PassmanBackupV1 {
format: "passman-backup";
version: 1;
exportedAt: string;
vault: string;
items: Array<{
id: string;
item_type: string;
encrypted_data: string;
location: "server" | "local";
created_at: string;
updated_at: string;
}>;
}

export function buildBackup(
vault: string,
items: LocatedItem[],
): PassmanBackupV1 {
return {
format: "passman-backup",
version: 1,
exportedAt: new Date().toISOString(),
vault: vault.toLowerCase(),
items: items.map((it) => ({
id: it.id,
item_type: it.item_type,
encrypted_data: it.encrypted_data,
location: it.location,
created_at: it.created_at,
updated_at: it.updated_at,
})),
};
}

/**
* Serialise the backup as JSON and trigger a browser download. The file
* is named `passman-<vault>-<YYYYMMDD>.json` with the vault email
* sanitised to filesystem-safe chars.
*/
export function downloadBackup(backup: PassmanBackupV1): void {
const json = JSON.stringify(backup, null, 2);
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
const safeVault = backup.vault.replace(/[^a-zA-Z0-9_.-]+/g, "-");
const date = backup.exportedAt.slice(0, 10).replace(/-/g, "");
a.download = `passman-${safeVault}-${date}.json`;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
1 change: 1 addition & 0 deletions packages/web/src/backup/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { buildBackup, downloadBackup, type PassmanBackupV1 } from "./export.js";
5 changes: 5 additions & 0 deletions packages/web/src/connect/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,10 @@ export {
export { buildJdbcUrl, supportsJdbc } from "./jdbc.js";
export { buildConnectCommand } from "./command.js";
export { buildSshUrl, launchSshUrl } from "./ssh.js";
export {
buildSshKeyCommand,
downloadSshKey,
looksLikePem,
} from "./sshkey.js";
export { buildRdpFile, downloadRdpFile } from "./rdp.js";
export { copySensitive, copyPlain, CLIPBOARD_CLEAR_MS } from "./clipboard.js";
56 changes: 56 additions & 0 deletions packages/web/src/connect/sshkey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { VaultLoginPlaintext } from "@passman/core";

/**
* Loose validation for a PEM-encoded private key. We don't try to parse
* the key — that needs a real ASN.1 / OpenSSH parser — we just check
* the begin/end marker bracketing so a paste error is caught early.
*
* Accepts any of:
* -----BEGIN OPENSSH PRIVATE KEY-----
* -----BEGIN RSA PRIVATE KEY-----
* -----BEGIN EC PRIVATE KEY-----
* -----BEGIN PRIVATE KEY-----
*/
const PEM_RE =
/-----BEGIN ([A-Z]+ )?PRIVATE KEY-----[\s\S]+?-----END \1?PRIVATE KEY-----/;

export function looksLikePem(text: string): boolean {
if (!text) return false;
return PEM_RE.test(text.trim());
}

/**
* Trigger a browser download of the credential's SSH key with mode-0600
* intent embedded in the filename. Returns false if no key is set.
*/
export function downloadSshKey(item: VaultLoginPlaintext): boolean {
if (!item.privateKey) return false;
// Always emit a trailing newline — `ssh -i` and OpenSSH itself reject
// some keys without one.
const content = item.privateKey.endsWith("\n")
? item.privateKey
: `${item.privateKey}\n`;
const blob = new Blob([content], { type: "application/x-pem-file" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
const safeName = (item.name || "passman-key").replace(/[^a-zA-Z0-9_-]+/g, "-");
a.download = `${safeName}.pem`;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
return true;
}

/** Build an `ssh -i ~/.ssh/passman/<name>.pem user@host` command for the
* copy-command action when a key is attached. */
export function buildSshKeyCommand(item: VaultLoginPlaintext): string | null {
const host = item.hostname || item.ip;
if (!host || !item.privateKey) return null;
const safeName = (item.name || "passman-key").replace(/[^a-zA-Z0-9_-]+/g, "-");
const userAt = item.username ? `${item.username}@` : "";
const portArg =
item.port && item.port !== 22 ? ` -p ${item.port}` : "";
return `ssh -i ~/.ssh/passman/${safeName}.pem ${userAt}${host}${portArg}`;
}
Loading
Loading