Requirement 001 phase 6, S0: - Speech: the security class shows as "Sécurité" and is spoken as in maritime radio, "say-cure-ee-tay" three times. A hex run of 7 or more characters with a letter and a digit (commit, session, message ids) is spelled in the NATO alphabet; every other digit is spoken on its own in ICAO pronunciation (42305: four two tree zero fife). The spelling runs inside clip() before the length cut, so a phrase still fits SPEECH_LIMIT; the displayed text keeps its digits and hashes. - English: every orchestrator label, tooltip, message, error, script output, document and test string is English (Regeln -> Firewall, Hierarchie -> Hierarchy, Steuerung -> Controls, Ausführen -> Run, ...); the page declares lang="en" and formats dates as en-GB. Task filter ids are English. German stays only in the skill trigger phrases that quote the operator, and as identifiers: the view keys (view=) and the leitstand id. - tests/test_english.py scans every tracked text file for umlauts and German words, with an allowlist of the quoted trigger phrases. BREAKING CHANGE: the Stream search tokens art: and seit: are now kind: and since:; an old art: or seit: token counts as a plain word. Evidence: make test 657 OK, ruff and markdownlint clean (/tmp/s0-make-test-1.log); e2e 17 OK, none skipped (/tmp/s0-e2e-1.log); MEDUSA 86 files, 24 findings on reworded strings accepted after an independent skeptic (21 path:line, 0 rejected, no behaviour change in signing, approving, rules, denying, offices, events), rescan 0 open. 🎯 Fix: 93 % 💥 Break: 4 % 💸 Cost: 100 % ⏱️ Time: – 🛡️ Security: 100 % 🏆 Score: 89 🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 % Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
92 lines
5.2 KiB
JavaScript
92 lines
5.2 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());
|
|
const KEEPASSXC_LOCALHOST = "KeePassXC refuses passkeys on http://localhost until “Allow using localhost with passkeys” is checked in KeePassXC under Tools → Settings → Browser Integration → Advanced; the database must be unlocked. Otherwise KeePassXC-Browser reports “Passkeys request canceled.” and Chrome opens its own passkey dialog.";
|
|
|
|
async function assertion(prepared, label) {
|
|
if (!onLocalhost()) {
|
|
toast(`${label}: not signed`, [`The passkey signs only under ${localUrl()}`], false);
|
|
return null;
|
|
}
|
|
if (!prepared.credentials.length) {
|
|
toast(`${label}: not signed`, ["No passkey pinned: Settings → Register passkey"], 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}: not signed`, [`${(error && error.name) || error}: KeePassXC locked or canceled; nothing was sent.`], false);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function signedProof(path, fields, label) {
|
|
const result = await sendJson(path, fields);
|
|
if (!result.ok || !result.data) {
|
|
toast(`${label}: not possible`, 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;
|
|
}
|
|
|
|
function proofFor(entries, label) {
|
|
return signedProof("/api/sign", {items: entries}, label);
|
|
}
|
|
|
|
async function registerPasskey(output) {
|
|
if (!onLocalhost()) {
|
|
output.replaceChildren(h("p", {class: "muted"}, "not registered: WebAuthn needs the domain ", localLink()));
|
|
return;
|
|
}
|
|
try {
|
|
const credential = await navigator.credentials.create({publicKey: {
|
|
rp: {id: RP_ID, name: "Agent Orchestrator"},
|
|
user: {id: crypto.getRandomValues(new Uint8Array(16)), name: "operator", displayName: "Agent Orchestrator 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-orchestrator/client/approval.py pin --port ${window.location.port} --approvers ~/.config/kvb-orchestrator/approvers.json --credential '${blob}'`;
|
|
output.replaceChildren(
|
|
h("p", {class: "muted"}, "Run this command in the host's terminal, outside any sandbox; only then does the passkey apply. The hub never pins by itself."),
|
|
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"}, `not registered: ${(error && error.name) || error}`), h("p", {class: "muted"}, KEEPASSXC_LOCALHOST));
|
|
}
|
|
}
|
|
|
|
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"}, "No passkey pinned: runner, hook and commit skills refuse every grant.");
|
|
return h("section", {}, h("h3", {}, "Passkey"),
|
|
h("p", {class: "muted"}, "A passkey in KeePassXC signs grants of asks, reviews and operator commands; runner, hook and commit skills check the signature themselves against the pinned keys."),
|
|
keys,
|
|
h("p", {class: "muted"}, KEEPASSXC_LOCALHOST),
|
|
onLocalhost() ? null : h("p", {class: "muted"}, "WebAuthn needs the domain: ", localLink()),
|
|
h("div", {}, btn("Register passkey", "btn small soft", () => registerPasskey(output))),
|
|
output);
|
|
}
|