Files
skills/hub/templates/page-passkey.js
T
Kevin Veen-BirkenbachandClaude Opus 5.5 8ba45efd04 feat(hub): kvb-hub review hub with passkey-signed approvals
The kvb-hub is a local review hub in a container bound to 127.0.0.1: one page, JSON APIs and an MCP endpoint for the operator's inbox, asks, decisions, staged-commit reviews, the radio stream, the agent graph, the worktree tree, push-to-talk and read-aloud radio (Piper, whisper.cpp) and an optional LLM command line. The hub is only a relay. The host executors in hub/client (the runner user service, the PermissionRequest hook and the approval CLI the commit skills call) verify a WebAuthn passkey signature against keys pinned outside every sandbox, refuse replays and fail closed. scripts/hub.sh with make hub-install, hub-check, hub-pin, hub-runner and hub-hook installs and checks the hub; hub-hook writes ~/.claude/settings.json after a backup. make watch-check shows a stuck watch hook. The new kvb-git-diff skill and the commit skills route reviews through the hub; the test_hub_* suites replace test_hub.py.

Security review (kvb-think-criticize, three skeptics) fixed four holes: signed rules auto-approved chained shell commands; the client and LLM openers followed redirects, the LLM one with its bearer key; the worktree scan ran git fsmonitor and filter commands from agent-writable repos; compose set no memory, pids or health limits. Documented until core requirement 093, on the operator's word: the hub page is trusted while signing and pinning, local processes can forge the unsigned operator actions, and the container reads the host /tmp. Committed on the operator's word despite the gate: the passkey path is not yet verified live.

Accepted MEDUSA false positives (137 findings on 115 lines, rows in security-accepted.jsonl): EXA002 x17, MEDUSA-MM-SCAN-058 x12, B603 x8, MEDUSA-FTSC-SCAN-089 x7, MEDUSA-PRIV-SCAN-762 x6, AIC-EXFIL-005 x5, EXA006 x5, MEDUSA-FTSCAN-231 x5, WEB-SSRF x5, MEDUSA-INF-SCAN-2057 x3, RH005 x3, TC001 x3, LO002 x2, MEDUSA-AEXP-SCAN-096 x2, MEDUSA-CGS-SCAN-028 x2, MEDUSA-INF-SCAN-1350 x2, MEDUSA-INF-SCAN-2369 x2, MEDUSA-INF-SCAN-2440 x2, MEDUSA-INF-SCAN-2638 x2, MLSC-SERIAL-002 x2, PL002 x2, PQC005 x2, SAST-PATH-001 x2, TC004 x2, TC005 x2, TC006 x2, TC007 x2, AA-146, EXA007, FINE-050, GB-ENCODE-001, GB-ENCODE-003, MEDUSA-CGS-SCAN-029, MEDUSA-CGS-SCAN-103, MEDUSA-DP-SCAN-1034, MEDUSA-DP-SCAN-1916, MEDUSA-DP-SCAN-954, MEDUSA-EXF26-TOOL-004, MEDUSA-FT-TRIGGER-005, MEDUSA-INF-SCAN-2206, MEDUSA-INF-SCAN-2457, MEDUSA-INF-WEB-003, MEDUSA-INFRA-SCAN-030, MEDUSA-MP-SCAN-1135, MEDUSA-RLHF-POISON-002, MEDUSA-RT-SCAN-006, MM-ENCODE-001, MP-124, PQC001, PQC002, PQC006, RP001, TC009, TC010, WEB-FLASK-004.

🎯 Fix: 80 %
💥 Break: 4 %
💸 Cost: 129 %
⏱️ Time: –
🛡️ Security: 100 %
💰 Value: ?
🏆 Score: 60
🚦 Gate: ⛔ fix 80 % < 90; cost 129 % > 100: ask the operator before the commit

```
/home/kevinveenbirkenbach/Repositories/github.com/kevinveenbirkenbach/skills/.worktrees/feature/kvb-hub
```

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 18:46:40 +00:00

86 lines
4.7 KiB
JavaScript

const RP_ID = "localhost";
const ALGORITHMS = [-7, -8, -257];
const onLocalhost = () => window.location.hostname === RP_ID;
const localUrl = () => `http://${RP_ID}:${window.location.port}${window.location.pathname}${window.location.hash}`;
const b64url = (buffer) => btoa(String.fromCharCode(...new Uint8Array(buffer))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const unb64url = (text) => Uint8Array.from(atob(text.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(text.length / 4) * 4, "=")), (char) => char.charCodeAt(0));
const localLink = () => h("a", {href: localUrl()}, localUrl());
async function assertion(prepared, label) {
if (!onLocalhost()) {
toast(`${label}: nicht signiert`, [`Der Passkey signiert nur unter ${localUrl()}`], false);
return null;
}
if (!prepared.credentials.length) {
toast(`${label}: nicht signiert`, ["Kein Passkey gepinnt: Einstellungen → Passkey registrieren"], false);
return null;
}
try {
const credential = await navigator.credentials.get({publicKey: {
challenge: unb64url(prepared.challenge),
rpId: RP_ID,
allowCredentials: prepared.credentials.map((id) => ({type: "public-key", id: unb64url(id)})),
userVerification: "preferred",
timeout: 120000,
}});
const answer = credential.response;
return {id: b64url(credential.rawId), clientDataJSON: b64url(answer.clientDataJSON), authenticatorData: b64url(answer.authenticatorData), signature: b64url(answer.signature)};
} catch (error) {
toast(`${label}: nicht signiert`, [`${(error && error.name) || error}: KeePassXC gesperrt oder abgebrochen; nichts wurde gesendet.`], false);
return null;
}
}
async function proofFor(entries, label) {
const result = await sendJson("/api/sign", {items: entries});
if (!result.ok || !result.data) {
toast(`${label}: nicht möglich`, fail(result), false);
return null;
}
if (result.data.payload === null) {
return {proof: null};
}
const signed = await assertion(result.data, label);
return signed ? {proof: {payload: result.data.payload, assertion: signed}} : null;
}
async function registerPasskey(output) {
if (!onLocalhost()) {
output.replaceChildren(h("p", {class: "muted"}, "nicht registriert: WebAuthn braucht die Domain ", localLink()));
return;
}
try {
const credential = await navigator.credentials.create({publicKey: {
rp: {id: RP_ID, name: "kvb-hub"},
user: {id: crypto.getRandomValues(new Uint8Array(16)), name: "operator", displayName: "kvb-hub Operator"},
challenge: crypto.getRandomValues(new Uint8Array(32)),
pubKeyCredParams: ALGORITHMS.map((alg) => ({type: "public-key", alg})),
attestation: "none",
authenticatorSelection: {residentKey: "preferred", userVerification: "preferred"},
timeout: 120000,
}});
const answer = credential.response;
const blob = b64url(new TextEncoder().encode(JSON.stringify({id: b64url(credential.rawId), attestationObject: b64url(answer.attestationObject), clientDataJSON: b64url(answer.clientDataJSON)})));
const command = `python3 ~/.local/share/kvb-hub/client/approval.py pin --port ${window.location.port} --approvers ~/.config/kvb-hub/approvers.json --credential '${blob}'`;
output.replaceChildren(
h("p", {class: "muted"}, "Diesen Befehl im Terminal des Hosts ausführen, außerhalb jeder Sandbox; erst dann gilt der Passkey. Der Hub pinnt nie selbst."),
h("div", {class: "command"}, h("code", {}, command), copyButton(command)),
h("label", {}, "Credential", h("textarea", {class: "field", readonly: true, rows: 3}, blob)));
} catch (error) {
output.replaceChildren(h("p", {class: "muted"}, `nicht registriert: ${(error && error.name) || error}`));
}
}
function passkeySection() {
const output = h("div", {class: "mini", "aria-live": "polite"});
const keys = state.approvers.length
? h("ul", {class: "mini"}, state.approvers.map((key) => h("li", {}, pill(key.alg, "ok"), " ", h("code", {title: key.id}, `${key.id.slice(0, 16)}…`))))
: h("p", {class: "muted"}, "Kein Passkey gepinnt: Runner, Hook und Commit-Skills verweigern jede Freigabe.");
return h("section", {}, h("h3", {}, "Passkey"),
h("p", {class: "muted"}, "Freigaben von Asks, Reviews und Operator-Befehlen signiert ein Passkey in KeePassXC; Runner, Hook und Commit-Skills prüfen die Signatur selbst gegen die gepinnten Schlüssel."),
keys,
onLocalhost() ? null : h("p", {class: "muted"}, "WebAuthn braucht die Domain: ", localLink()),
h("div", {}, btn("Passkey registrieren", "btn small soft", () => registerPasskey(output))),
output);
}