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:
2026-10-16 20:13:54 +09:00
co-authored by Claude Opus 5.5
parent 30cdcee3d2
commit 923c466dea
44 changed files with 9289 additions and 270 deletions
+209 -38
View File
@@ -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();