M3: working desktop app + browser pairing channel
- Builds and runs (tauri build --debug); placeholder icons; persistent VaultCore::open in the OS app-data dir; Secret Key remembered on the device; tray keeps the app serving the extension. - Auto-lock: idle (system idle on Windows/macOS, in-app activity on Linux), screen lock, sleep. - Local IPC for kestrove-nmh: 0700 dir + 0600 Unix socket with peer-uid checks; Windows named pipe (first instance, local only, user DACL, client SID check) behind cfg. - Pairing channel: X25519 → HKDF → 6-word fingerprint confirmed in both UIs, per-connection session keys, AES-256-GCM with strict counters. Test vectors shared with kestrove-browser. - Broker: query/fill/totp/save with origin re-checks; pairing and save prompts in the UI. - kestrove-nmh relays ciphertext only; native-host installers for Linux/macOS/Windows. - Tests: unit tests, tests/nmh_stdio.rs (real kestrove-nmh over stdio), examples/test-host. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
+209
-38
@@ -1,64 +1,90 @@
|
||||
// Minimal desktop UI. All vault logic runs in Rust (src-tauri → kv-ffi); this layer only renders.
|
||||
// Desktop UI. All vault logic runs in Rust (src-tauri → kv-ffi); this layer only renders.
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
interface ItemSummary { id: string; title: string; username: string | null; urls: string[]; favorite: boolean }
|
||||
interface AppState { has_account: boolean; locked: boolean; remembers_secret_key: boolean; ipc_error: string | null; idle_minutes: number }
|
||||
interface ItemSummary { id: string; title: string; username: string | null; urls: string[]; favorite: boolean; has_totp: boolean }
|
||||
interface ItemDto { id: string; title: string; username: string | null; password: string | null; urls: string[]; totp: string | null; notes: string | null }
|
||||
interface PairingView { id: string; name: string; caller: string; words: string[] }
|
||||
interface Pairing { id: string; name: string; caller: string; words: string[]; created_at: number }
|
||||
interface SaveView { id: string; origin: string; username: string; update: boolean }
|
||||
|
||||
const app = document.getElementById("app")!;
|
||||
const overlay = document.getElementById("overlay")!;
|
||||
|
||||
function el<K extends keyof HTMLElementTagNameMap>(tag: K, props: Record<string, unknown> = {}, ...children: (Node | string)[]): HTMLElementTagNameMap[K] {
|
||||
function el<K extends keyof HTMLElementTagNameMap>(tag: K, props: Partial<HTMLElementTagNameMap[K]> & Record<string, unknown> = {}, ...children: (Node | string)[]): HTMLElementTagNameMap[K] {
|
||||
const e: HTMLElementTagNameMap[K] = Object.assign(document.createElement(tag), props);
|
||||
e.append(...children);
|
||||
return e;
|
||||
}
|
||||
|
||||
const errText = (e: unknown) => (typeof e === "string" ? e : e instanceof Error ? e.message : String(e));
|
||||
|
||||
async function render() {
|
||||
const state = await invoke<{ has_account: boolean; locked: boolean }>("state");
|
||||
const state = await invoke<AppState>("state");
|
||||
app.replaceChildren();
|
||||
if (!state.has_account) return renderCreate();
|
||||
if (state.locked) return renderUnlock();
|
||||
return renderVault();
|
||||
if (!state.has_account) renderCreate();
|
||||
else if (state.locked) renderUnlock(state);
|
||||
else await renderVault(state);
|
||||
await renderPrompts(state);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ account
|
||||
|
||||
function renderCreate() {
|
||||
const pw = el("input", { type: "password", placeholder: "Master password (10+ chars)" });
|
||||
const err = el("div", { className: "err" });
|
||||
const btn = el("button", { textContent: "Create vault" });
|
||||
const btn = el("button", { textContent: "Create vault", className: "primary" });
|
||||
const card = el("div", { className: "card" }, el("h2", { textContent: "Create your Kestrove vault" }), pw, btn, err);
|
||||
btn.onclick = async () => {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const secretKey = await invoke<string>("create_account", { masterPassword: pw.value });
|
||||
pw.value = "";
|
||||
card.replaceChildren(
|
||||
el("h2", { textContent: "Your Secret Key" }),
|
||||
el("p", { className: "mono", textContent: secretKey }),
|
||||
el("p", { className: "muted", textContent: "Write this down in your Emergency Kit. You need it to add devices." }),
|
||||
el("button", { textContent: "Continue", onclick: render }),
|
||||
el("p", { className: "muted", textContent: "Write this down in your Emergency Kit. You need it to add devices. This device remembers it." }),
|
||||
el("button", { textContent: "Continue", className: "primary", onclick: render }),
|
||||
);
|
||||
} catch (e) {
|
||||
err.textContent = String(e);
|
||||
err.textContent = errText(e);
|
||||
btn.disabled = false;
|
||||
}
|
||||
};
|
||||
app.append(el("div", { className: "center" }, card));
|
||||
pw.focus();
|
||||
}
|
||||
|
||||
function renderUnlock() {
|
||||
function renderUnlock(state: AppState) {
|
||||
const pw = el("input", { type: "password", placeholder: "Master password" });
|
||||
const sk = el("input", { placeholder: "Secret Key (K1-…)", className: "mono" });
|
||||
const err = el("div", { className: "err" });
|
||||
const btn = el("button", { textContent: "Unlock" });
|
||||
btn.onclick = async () => {
|
||||
const btn = el("button", { textContent: "Unlock", className: "primary" });
|
||||
const submit = async () => {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await invoke("unlock", { masterPassword: pw.value, secretKey: sk.value });
|
||||
await invoke("unlock", { masterPassword: pw.value, secretKey: sk.value || null });
|
||||
pw.value = "";
|
||||
sk.value = "";
|
||||
await render();
|
||||
} catch (e) {
|
||||
err.textContent = String(e);
|
||||
err.textContent = errText(e);
|
||||
btn.disabled = false;
|
||||
}
|
||||
};
|
||||
app.append(el("div", { className: "center" }, el("div", { className: "card" }, el("h2", { textContent: "Kestrove is locked" }), pw, sk, btn, err)));
|
||||
btn.onclick = submit;
|
||||
pw.onkeydown = (e) => e.key === "Enter" && void submit();
|
||||
const card = el("div", { className: "card" }, el("h2", { textContent: "Kestrove is locked" }), pw);
|
||||
if (!state.remembers_secret_key) card.append(sk);
|
||||
card.append(btn, err);
|
||||
app.append(el("div", { className: "center" }, card));
|
||||
pw.focus();
|
||||
}
|
||||
|
||||
async function renderVault() {
|
||||
// ------------------------------------------------------------------ vault
|
||||
|
||||
async function renderVault(state: AppState) {
|
||||
const items = await invoke<ItemSummary[]>("list_items");
|
||||
const detail = el("section");
|
||||
const list = el("ul");
|
||||
@@ -67,24 +93,55 @@ async function renderVault() {
|
||||
li.onclick = async () => {
|
||||
list.querySelectorAll("li").forEach((n) => n.classList.remove("sel"));
|
||||
li.classList.add("sel");
|
||||
const item = await invoke<ItemDto>("get_item", { id: it.id });
|
||||
const pwSpan = el("span", { className: "mono", textContent: "••••••••" });
|
||||
detail.replaceChildren(
|
||||
el("h2", { textContent: item.title }),
|
||||
el("p", {}, "Username: ", el("span", { className: "mono", textContent: item.username ?? "" })),
|
||||
el("p", {}, "Password: ", pwSpan, " ",
|
||||
el("button", { textContent: "Reveal", onclick: () => (pwSpan.textContent = item.password ?? "") }),
|
||||
" ",
|
||||
el("button", { textContent: "Copy", onclick: () => invoke("copy_secret", { id: item.id }) })),
|
||||
el("p", { className: "muted", textContent: item.urls.join(", ") }),
|
||||
);
|
||||
await renderDetail(detail, it.id);
|
||||
};
|
||||
list.append(li);
|
||||
}
|
||||
const add = el("button", { textContent: "+ New login" });
|
||||
add.onclick = () => renderEditor(detail);
|
||||
if (!items.length) list.append(el("li", { className: "muted", textContent: "No items yet." }));
|
||||
const add = el("button", { textContent: "+ New login", onclick: () => renderEditor(detail) });
|
||||
const settings = el("button", { textContent: "Settings", onclick: () => renderSettings(detail, state) });
|
||||
const lock = el("button", { textContent: "Lock", onclick: async () => { await invoke("lock"); await render(); } });
|
||||
app.append(el("aside", {}, el("div", { className: "toolbar" }, add, lock), list), detail);
|
||||
const aside = el("aside", {}, el("div", { className: "toolbar" }, add, settings, lock), list);
|
||||
if (state.ipc_error) aside.append(el("p", { className: "err pad", textContent: state.ipc_error }));
|
||||
app.append(aside, detail);
|
||||
}
|
||||
|
||||
async function renderDetail(target: HTMLElement, id: string) {
|
||||
const item = await invoke<ItemDto>("get_item", { id });
|
||||
const pwSpan = el("span", { className: "mono", textContent: "••••••••" });
|
||||
const rows: Node[] = [
|
||||
el("h2", { textContent: item.title }),
|
||||
el("p", {}, "Username: ", el("span", { className: "mono", textContent: item.username ?? "" }), " ",
|
||||
el("button", { textContent: "Copy", onclick: () => invoke("copy_secret", { id, field: "username" }) })),
|
||||
el("p", {}, "Password: ", pwSpan, " ",
|
||||
el("button", { textContent: "Reveal", onclick: () => (pwSpan.textContent = item.password ?? "") }), " ",
|
||||
el("button", { textContent: "Copy", onclick: () => invoke("copy_secret", { id, field: "password" }) })),
|
||||
];
|
||||
if (item.totp) {
|
||||
const code = el("span", { className: "mono" });
|
||||
const tick = async () => {
|
||||
if (!code.isConnected) return;
|
||||
const t = await invoke<{ code: string; seconds_remaining: number } | null>("totp", { id }).catch(() => null);
|
||||
code.textContent = t ? `${t.code} (${t.seconds_remaining}s)` : "—";
|
||||
setTimeout(tick, 1000);
|
||||
};
|
||||
rows.push(el("p", {}, "One-time code: ", code, " ", el("button", { textContent: "Copy", onclick: () => invoke("copy_secret", { id, field: "totp" }) })));
|
||||
queueMicrotask(tick);
|
||||
}
|
||||
rows.push(
|
||||
el("p", { className: "muted", textContent: item.urls.join(", ") }),
|
||||
el("button", {
|
||||
textContent: "Delete",
|
||||
className: "danger",
|
||||
onclick: async () => {
|
||||
if (confirm(`Delete “${item.title}”?`)) {
|
||||
await invoke("delete_item", { id });
|
||||
await render();
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
target.replaceChildren(...rows);
|
||||
}
|
||||
|
||||
function renderEditor(target: HTMLElement) {
|
||||
@@ -92,15 +149,129 @@ function renderEditor(target: HTMLElement) {
|
||||
const user = el("input", { placeholder: "Username" });
|
||||
const pass = el("input", { placeholder: "Password", className: "mono" });
|
||||
const url = el("input", { placeholder: "https://example.com" });
|
||||
const totp = el("input", { placeholder: "TOTP secret or otpauth:// URI (optional)", className: "mono" });
|
||||
const gen = el("button", { textContent: "Generate", onclick: async () => (pass.value = await invoke<string>("generate_password", { length: 24 })) });
|
||||
const save = el("button", { textContent: "Save" });
|
||||
const err = el("div", { className: "err" });
|
||||
const save = el("button", { textContent: "Save", className: "primary" });
|
||||
save.onclick = async () => {
|
||||
await invoke("save_item", {
|
||||
item: { title: title.value, username: user.value || null, password: pass.value || null, urls: url.value ? [url.value] : [] },
|
||||
});
|
||||
await render();
|
||||
try {
|
||||
await invoke("save_item", {
|
||||
item: { title: title.value, username: user.value || null, password: pass.value || null, urls: url.value ? [url.value] : [], totp: totp.value || null },
|
||||
});
|
||||
await render();
|
||||
} catch (e) {
|
||||
err.textContent = errText(e);
|
||||
}
|
||||
};
|
||||
target.replaceChildren(el("div", { className: "card" }, el("h2", { textContent: "New login" }), title, user, pass, gen, url, save));
|
||||
target.replaceChildren(el("div", { className: "card" }, el("h2", { textContent: "New login" }), title, user, el("div", { className: "row" }, pass, gen), url, totp, save, err));
|
||||
}
|
||||
|
||||
async function renderSettings(target: HTMLElement, state: AppState) {
|
||||
const pairings = await invoke<Pairing[]>("list_pairings");
|
||||
const idle = el("select", {}, ...[1, 5, 10, 15, 30, 60, 120].map((m) => el("option", { value: String(m), textContent: `${m} min`, selected: m === state.idle_minutes })));
|
||||
idle.onchange = () => invoke("set_idle_minutes", { minutes: Number(idle.value) });
|
||||
const list = el("ul", { className: "plain" });
|
||||
for (const p of pairings) {
|
||||
list.append(
|
||||
el("li", {},
|
||||
el("div", { textContent: `${p.name}${p.caller ? ` · ${p.caller}` : ""}` }),
|
||||
el("div", { className: "muted mono", textContent: p.words.join(" ") }),
|
||||
el("div", { className: "muted", textContent: `Paired ${new Date(p.created_at * 1000).toLocaleString()}` }),
|
||||
el("button", {
|
||||
textContent: "Unpair",
|
||||
onclick: async () => {
|
||||
await invoke("revoke_pairing", { id: p.id });
|
||||
await renderSettings(target, state);
|
||||
},
|
||||
})),
|
||||
);
|
||||
}
|
||||
if (!pairings.length) list.append(el("li", { className: "muted", textContent: "No browsers paired. Open the Kestrove extension in your browser and choose “Pair”." }));
|
||||
target.replaceChildren(
|
||||
el("h2", { textContent: "Settings" }),
|
||||
el("label", { className: "row" }, "Lock after idle for ", idle),
|
||||
el("p", { className: "muted", textContent: "Kestrove also locks on screen lock and sleep." }),
|
||||
el("h3", { textContent: "Paired browsers" }),
|
||||
list,
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ browser prompts (pairing, save)
|
||||
|
||||
async function renderPrompts(state: AppState) {
|
||||
overlay.replaceChildren();
|
||||
const pairing = await invoke<PairingView | null>("pending_pairing");
|
||||
if (pairing) overlay.append(pairingCard(pairing, state));
|
||||
if (state.has_account && !state.locked) {
|
||||
for (const s of await invoke<SaveView[]>("pending_saves")) overlay.append(saveCard(s));
|
||||
} else if (state.has_account && (await invoke<SaveView[]>("pending_saves")).length) {
|
||||
overlay.append(el("div", { className: "prompt" }, el("p", { textContent: "Unlock Kestrove to review logins your browser asked to save." })));
|
||||
}
|
||||
overlay.hidden = !overlay.childElementCount;
|
||||
}
|
||||
|
||||
function pairingCard(p: PairingView, state: AppState): HTMLElement {
|
||||
const err = el("div", { className: "err" });
|
||||
const words = el("div", { className: "words" }, ...p.words.map((w) => el("span", { textContent: w })));
|
||||
const accept = el("button", { textContent: "The words match: pair", className: "primary" });
|
||||
const reject = el("button", { textContent: "Don't pair" });
|
||||
const resolve = async (yes: boolean) => {
|
||||
try {
|
||||
await invoke("resolve_pairing", { id: p.id, accept: yes });
|
||||
await render();
|
||||
} catch (e) {
|
||||
err.textContent = errText(e);
|
||||
}
|
||||
};
|
||||
accept.onclick = () => resolve(true);
|
||||
reject.onclick = () => resolve(false);
|
||||
accept.disabled = state.locked;
|
||||
return el("div", { className: "prompt" },
|
||||
el("h3", { textContent: "Pair a browser?" }),
|
||||
el("p", { textContent: `${p.name}${p.caller ? ` (${p.caller})` : ""} wants to use Kestrove. Check that the extension shows exactly these words:` }),
|
||||
words,
|
||||
state.locked ? el("p", { className: "muted", textContent: "Unlock Kestrove first, then confirm." }) : "",
|
||||
el("div", { className: "row" }, accept, reject),
|
||||
err);
|
||||
}
|
||||
|
||||
function saveCard(s: SaveView): HTMLElement {
|
||||
const err = el("div", { className: "err" });
|
||||
const yes = el("button", { textContent: s.update ? "Update password" : "Save", className: "primary" });
|
||||
const no = el("button", { textContent: "Not now" });
|
||||
const resolve = async (accept: boolean) => {
|
||||
try {
|
||||
await invoke("resolve_save", { id: s.id, accept });
|
||||
await render();
|
||||
} catch (e) {
|
||||
err.textContent = errText(e);
|
||||
}
|
||||
};
|
||||
yes.onclick = () => resolve(true);
|
||||
no.onclick = () => resolve(false);
|
||||
return el("div", { className: "prompt" },
|
||||
el("h3", { textContent: s.update ? "Update saved password?" : "Save this login?" }),
|
||||
el("p", {}, el("span", { className: "mono", textContent: s.username || "(no username)" }), " on ", el("span", { className: "mono", textContent: s.origin })),
|
||||
el("div", { className: "row" }, yes, no),
|
||||
err);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ wiring
|
||||
|
||||
void listen("pairing-request", () => render());
|
||||
void listen("save-request", () => render());
|
||||
void listen("locked", () => render());
|
||||
|
||||
// Activity for the idle timer (Linux has no system idle time without extra deps).
|
||||
let lastTouch = 0;
|
||||
for (const ev of ["keydown", "mousedown", "mousemove"]) {
|
||||
document.addEventListener(ev, () => {
|
||||
const now = Date.now();
|
||||
if (now - lastTouch > 30_000) {
|
||||
lastTouch = now;
|
||||
void invoke("touch");
|
||||
}
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
void render();
|
||||
|
||||
Reference in New Issue
Block a user