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
30 changes: 30 additions & 0 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,36 @@ The form is **protocol-aware**:
Empty fields are stripped before encryption, so the ciphertext stays
small and forward-compatible.

### 5a. Store on this device only

For credentials you don't want stored remotely **even encrypted**, the
form has a **Store on this device only** toggle below the URL field.
When ticked:

- The encrypted ciphertext is written to **IndexedDB** in this browser
(using the same vault key, so locking the vault still locks the local
items).
- Nothing about the credential — not even the ciphertext — touches
the server.
- Items appear in the grid with a green **DEVICE** badge next to the
name, so you can tell at a glance which credentials are local-only.

> ⚠️ **The trade-offs are real.** A local-only credential:
>
> - Does **not sync** to other devices or browsers.
> - Is **wiped** if you clear site data, "Reset to defaults", or the OS
> user profile is deleted.
> - Has **no off-device backup** — if this disk dies, the credential
> dies with it.
>
> Use device-only storage for items where the security benefit
> ("never on a server, even encrypted") outweighs the convenience cost
> ("only on this machine"). Most credentials should stay on the
> server-default for the sync + backup guarantees.

The toggle defaults to off, so existing zero-knowledge guarantees apply
to everyone who doesn't opt in.

---

## 6. Chrome extension
Expand Down
Binary file modified docs/img/vault-add.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/img/vault-connect.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/img/vault.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 13 additions & 0 deletions docs/preview/vault-add.html
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,19 @@ <h2>New credential</h2>
</label>
</div>

<label class="storage-toggle on">
<input type="checkbox" checked />
<span class="storage-toggle-body">
<span class="storage-toggle-title">Store on this device only</span>
<span class="storage-toggle-hint">
Encrypted ciphertext stays in this browser's IndexedDB · never sent to the server.<br />
<strong>No cross-device sync</strong> · cleared if you clear site data ·
single point of failure if this disk dies. Use for credentials you never
want stored remotely, even encrypted.
</span>
</span>
</label>

<div class="add-form-actions">
<button type="submit">Save</button>
<button type="button">Cancel</button>
Expand Down
15 changes: 9 additions & 6 deletions docs/preview/vault.html
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ <h1>All credentials</h1>
<div class="name-cell">
<span class="engine-tile e-psql">PG</span>
<div class="name-block">
<div class="name">prod-pg-primary</div>
<div class="name-row"><span class="name">prod-pg-primary</span></div>
<span class="url">postgres://db-prod-01:5432/app</span>
</div>
</div>
Expand All @@ -149,7 +149,7 @@ <h1>All credentials</h1>
<div class="name-cell">
<span class="engine-tile e-ssh">SSH</span>
<div class="name-block">
<div class="name">prod-pg-replica</div>
<div class="name-row"><span class="name">prod-pg-replica</span></div>
<span class="url">ssh://postgres@db-prod-02:22</span>
</div>
</div>
Expand All @@ -173,7 +173,10 @@ <h1>All credentials</h1>
<div class="name-cell">
<span class="engine-tile e-rdp">RDP</span>
<div class="name-block">
<div class="name">oracle-erp-bastion</div>
<div class="name-row">
<span class="name">oracle-erp-bastion</span>
<span class="storage-badge" title="Stored on this device only">Device</span>
</div>
<span class="url">rdp://erp-db-01.example.az:3389</span>
</div>
</div>
Expand All @@ -197,7 +200,7 @@ <h1>All credentials</h1>
<div class="name-cell">
<span class="engine-tile e-mysql">MY</span>
<div class="name-block">
<div class="name">mysql-reporting</div>
<div class="name-row"><span class="name">mysql-reporting</span></div>
<span class="url">mysql://rpt-db-01:3306/reports</span>
</div>
</div>
Expand All @@ -221,7 +224,7 @@ <h1>All credentials</h1>
<div class="name-cell">
<span class="engine-tile e-redis">RD</span>
<div class="name-block">
<div class="name">redis-cache</div>
<div class="name-row"><span class="name">redis-cache</span></div>
<span class="url">rediss://cache-01:6379</span>
</div>
</div>
Expand All @@ -245,7 +248,7 @@ <h1>All credentials</h1>
<div class="name-cell">
<span class="engine-tile e-mongo">MG</span>
<div class="name-block">
<div class="name">mongo-shard-a</div>
<div class="name-row"><span class="name">mongo-shard-a</span></div>
<span class="url">mongodb://mongo-01:27017</span>
</div>
</div>
Expand Down
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"fake-indexeddb": "^6.2.5",
"typescript": "^5.7.0",
"vite": "^6.0.3",
"vitest": "^4.1.5"
Expand Down
45 changes: 35 additions & 10 deletions packages/web/src/pages/VaultPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ import {
effectiveProtocol,
protocolLabel,
} from "../connect/index.js";
import {
createItem as createStored,
deleteItem as deleteStored,
listAll as listAllStored,
type StorageLocation,
} from "../storage/index.js";
import { useSession } from "../stores/session.js";
import { AddCredentialForm } from "./vault/AddCredentialForm.js";
import { ConnectDialog } from "./vault/ConnectDialog.js";
Expand Down Expand Up @@ -104,13 +110,13 @@ export function VaultPage() {
}

async function loadItems() {
if (!accessToken || !vault) return;
if (!accessToken || !vault || !email) return;
setLoading(true);
setError(null);
try {
const { items: encrypted } = await api.listItems(accessToken);
const located = await listAllStored(accessToken, email);
const decoded = await Promise.all(
encrypted.map(async (it) => ({
located.map(async (it) => ({
...it,
plaintext: await decryptVaultLogin(it.encrypted_data, vault),
})),
Expand All @@ -123,16 +129,26 @@ export function VaultPage() {
}
}

async function onAdd(plaintext: VaultLoginPlaintext) {
if (!accessToken || !vault) return;
async function onAdd(
plaintext: VaultLoginPlaintext,
location: StorageLocation,
) {
if (!accessToken || !vault || !email) return;
const encoded = await encryptVaultLogin(plaintext, vault);
await api.createItem(accessToken, {
await createStored({
accessToken,
vault: email,
item_type: encoded.itemType,
encrypted_data: encoded.encryptedData,
location,
});
setAdding(false);
await loadItems();
showToast("Credential added");
showToast(
location === "local"
? "Credential added — stored on this device only"
: "Credential added",
);
}

async function onLogout() {
Expand All @@ -149,9 +165,11 @@ export function VaultPage() {

async function onDelete(id: string) {
if (!accessToken) return;
const target = items.find((it) => it.id === id);
if (!target) return;
if (!confirm("Delete this credential?")) return;
try {
await api.deleteItem(accessToken, id);
await deleteStored(accessToken, id, target.location);
setSelected((s) => {
const next = new Set(s);
next.delete(id);
Expand All @@ -168,13 +186,20 @@ export function VaultPage() {
if (!accessToken) return;
if (selected.size === 0) return;
if (!confirm(`Delete ${selected.size} credential(s)?`)) return;
// Map each id to its location so the facade routes to the right store.
const byId = new Map(items.map((it) => [it.id, it.location]));
try {
await Promise.all(
[...selected].map((id) => api.deleteItem(accessToken, id)),
[...selected].map((id) => {
const loc = byId.get(id);
if (!loc) return Promise.resolve();
return deleteStored(accessToken, id, loc);
}),
);
const count = selected.size;
setSelected(new Set());
await loadItems();
showToast(`${selected.size} credential(s) deleted`);
showToast(`${count} credential(s) deleted`);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to delete");
}
Expand Down
27 changes: 25 additions & 2 deletions packages/web/src/pages/vault/AddCredentialForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useEffect, useState } from "react";
import type { Protocol, VaultLoginPlaintext } from "@passman/core";

import { defaultPort, inferProtocolFromPort } from "../../connect/index.js";
import type { StorageLocation } from "../../storage/index.js";

interface DraftState {
name: string;
Expand Down Expand Up @@ -50,12 +51,13 @@ const PROTOCOL_OPTIONS: { value: Protocol | ""; label: string }[] = [
];

interface Props {
onSubmit: (item: VaultLoginPlaintext) => Promise<void>;
onSubmit: (item: VaultLoginPlaintext, location: StorageLocation) => Promise<void>;
onCancel: () => void;
}

export function AddCredentialForm({ onSubmit, onCancel }: Props) {
const [draft, setDraft] = useState<DraftState>(EMPTY_DRAFT);
const [storeLocally, setStoreLocally] = useState(false);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);

Expand Down Expand Up @@ -96,8 +98,9 @@ export function AddCredentialForm({ onSubmit, onCancel }: Props) {
...(draft.environment ? { environment: draft.environment } : {}),
...(draft.url ? { url: draft.url } : {}),
};
await onSubmit(cleaned);
await onSubmit(cleaned, storeLocally ? "local" : "server");
setDraft(EMPTY_DRAFT);
setStoreLocally(false);
} catch (e) {
setErr(e instanceof Error ? e.message : "Failed to add credential");
} finally {
Expand Down Expand Up @@ -239,6 +242,26 @@ export function AddCredentialForm({ onSubmit, onCancel }: Props) {
</label>
</div>

<label className={`storage-toggle ${storeLocally ? "on" : ""}`}>
<input
type="checkbox"
checked={storeLocally}
onChange={(e) => setStoreLocally(e.target.checked)}
/>
<span className="storage-toggle-body">
<span className="storage-toggle-title">
Store on this device only
</span>
<span className="storage-toggle-hint">
Encrypted ciphertext stays in this browser's IndexedDB · never sent to the server.
<br />
<strong>No cross-device sync</strong> · cleared if you clear site data ·
single point of failure if this disk dies. Use for credentials you never
want stored remotely, even encrypted.
</span>
</span>
</label>

{err && <p className="error">{err}</p>}

<div className="add-form-actions">
Expand Down
12 changes: 10 additions & 2 deletions packages/web/src/pages/vault/CredentialsGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,16 @@ function Row({
{engineCode(protocol)}
</span>
<div className="name-block">
<div className="name" title={p.name}>
{p.name}
<div className="name-row">
<span className="name" title={p.name}>{p.name}</span>
{item.location === "local" && (
<span
className="storage-badge"
title="Stored on this device only — never sent to the server"
>
Device
</span>
)}
</div>
{p.url && (
<span className="url" title={p.url}>
Expand Down
6 changes: 4 additions & 2 deletions packages/web/src/pages/vault/types.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { VaultLoginPlaintext } from "@passman/core";

import type { VaultItem } from "../../api/client.js";
import type { LocatedItem, StorageLocation } from "../../storage/index.js";

export interface DecryptedItem extends VaultItem {
export type { StorageLocation };

export interface DecryptedItem extends LocatedItem {
plaintext: VaultLoginPlaintext;
}

Expand Down
9 changes: 9 additions & 0 deletions packages/web/src/storage/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export { createItem, deleteItem, listAll, type LocatedItem, type StorageLocation } from "./unified.js";
export {
clearLocalStore,
countLocalItems,
createLocalItem,
deleteLocalItem,
listLocalItems,
type LocalItemRecord,
} from "./local.js";
Loading
Loading