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>
240 lines
10 KiB
Python
240 lines
10 KiB
Python
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
import json
|
|
import re
|
|
import subprocess
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
from signing import RP_ID, Refused, b64encode, challenge
|
|
|
|
try:
|
|
from cryptography.exceptions import InvalidSignature, UnsupportedAlgorithm
|
|
from cryptography.hazmat.primitives import hashes, serialization
|
|
from cryptography.hazmat.primitives.asymmetric import ec, ed25519, padding, rsa
|
|
except ImportError:
|
|
serialization = None
|
|
|
|
FLAG_UP = 0x01
|
|
FLAG_AT = 0x40
|
|
EC_PREFIX = bytes.fromhex("3059301306072a8648ce3d020106082a8648ce3d030107034200")
|
|
ED_PREFIX = bytes.fromhex("302a300506032b6570032100")
|
|
RSA_IDENTIFIER = bytes.fromhex("300d06092a864886f70d0101010500")
|
|
B64URL = re.compile(r"[A-Za-z0-9_-]+")
|
|
ASSERTION_KEYS = ("id", "clientDataJSON", "authenticatorData", "signature")
|
|
CBOR_DEPTH = 16
|
|
TIMEOUT = 30
|
|
|
|
|
|
def b64decode(text: object, name: str) -> bytes:
|
|
if not isinstance(text, str) or not B64URL.fullmatch(text):
|
|
raise Refused(f"{name} is no base64url text")
|
|
try:
|
|
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
|
|
except ValueError as error:
|
|
raise Refused(f"{name} is no base64url text") from error
|
|
|
|
|
|
def cbor(data: bytes, offset: int, depth: int) -> tuple[object, int]:
|
|
if depth > CBOR_DEPTH or offset >= len(data):
|
|
raise Refused("the CBOR data is truncated or nests too deep")
|
|
major, info = data[offset] >> 5, data[offset] & 0x1F
|
|
offset += 1
|
|
value = info
|
|
if 24 <= info <= 27:
|
|
size = 1 << (info - 24)
|
|
if offset + size > len(data):
|
|
raise Refused("the CBOR data is truncated")
|
|
value = int.from_bytes(data[offset : offset + size], "big")
|
|
offset += size
|
|
elif info > 27:
|
|
raise Refused("the CBOR data uses an indefinite or reserved length")
|
|
if major == 0:
|
|
return value, offset
|
|
if major == 1:
|
|
return -1 - value, offset
|
|
if major in (2, 3):
|
|
chunk = data[offset : offset + value]
|
|
if len(chunk) != value:
|
|
raise Refused("the CBOR data is truncated")
|
|
if major == 2:
|
|
return chunk, offset + value
|
|
try:
|
|
return chunk.decode("utf-8"), offset + value
|
|
except UnicodeDecodeError as error:
|
|
raise Refused("the CBOR text is no UTF-8") from error
|
|
if major == 4:
|
|
items = []
|
|
for _ in range(value):
|
|
item, offset = cbor(data, offset, depth + 1)
|
|
items.append(item)
|
|
return items, offset
|
|
if major == 5:
|
|
entries: dict = {}
|
|
for _ in range(value):
|
|
key, offset = cbor(data, offset, depth + 1)
|
|
if isinstance(key, bool) or not isinstance(key, int | str) or key in entries:
|
|
raise Refused("the CBOR map holds a bad or repeated key")
|
|
entries[key], offset = cbor(data, offset, depth + 1)
|
|
return entries, offset
|
|
if major == 7 and info in (20, 21, 22):
|
|
return {20: False, 21: True, 22: None}[info], offset
|
|
raise Refused("the CBOR data holds an unsupported item")
|
|
|
|
|
|
def der(tag: int, body: bytes) -> bytes:
|
|
size = len(body)
|
|
if size < 0x80:
|
|
return bytes([tag, size]) + body
|
|
octets = size.to_bytes((size.bit_length() + 7) // 8, "big")
|
|
return bytes([tag, 0x80 | len(octets)]) + octets + body
|
|
|
|
|
|
def der_integer(value: bytes) -> bytes:
|
|
digits = value.lstrip(b"\x00") or b"\x00"
|
|
return der(0x02, b"\x00" + digits if digits[0] & 0x80 else digits)
|
|
|
|
|
|
def octets(value: object, size: int) -> bool:
|
|
return isinstance(value, bytes) and len(value) == size
|
|
|
|
|
|
def cose_key(key: object) -> tuple[int, bytes]:
|
|
if not isinstance(key, dict):
|
|
raise Refused("the credential public key is no COSE map")
|
|
kty, alg = key.get(1), key.get(3)
|
|
if kty == 2 and alg == -7 and key.get(-1) == 1 and octets(key.get(-2), 32) and octets(key.get(-3), 32):
|
|
return alg, EC_PREFIX + b"\x04" + key[-2] + key[-3]
|
|
if kty == 1 and alg == -8 and key.get(-1) == 6 and octets(key.get(-2), 32):
|
|
return alg, ED_PREFIX + key[-2]
|
|
if kty == 3 and alg == -257 and isinstance(key.get(-1), bytes) and isinstance(key.get(-2), bytes) and key[-1] and key[-2]:
|
|
public = der(0x30, der_integer(key[-1]) + der_integer(key[-2]))
|
|
return alg, der(0x30, RSA_IDENTIFIER + der(0x03, b"\x00" + public))
|
|
raise Refused("the key is none of ES256 on P-256, EdDSA on Ed25519 or RS256")
|
|
|
|
|
|
def client_data(raw: bytes, kind: str, origin: str) -> dict:
|
|
try:
|
|
client = json.loads(raw)
|
|
except (ValueError, RecursionError) as error:
|
|
raise Refused("clientDataJSON is no JSON") from error
|
|
if not isinstance(client, dict) or client.get("type") != kind:
|
|
raise Refused(f"clientDataJSON is no {kind}")
|
|
if client.get("origin") != origin or client.get("crossOrigin") is True:
|
|
raise Refused(f"the origin is not {origin}")
|
|
return client
|
|
|
|
|
|
def rp_flags(data: bytes, size: int) -> int:
|
|
if len(data) < size or data[:32] != hashlib.sha256(RP_ID.encode("ascii")).digest():
|
|
raise Refused(f"the rpIdHash is not the one of {RP_ID}")
|
|
if not data[32] & FLAG_UP:
|
|
raise Refused("the user presence flag is missing")
|
|
return data[32]
|
|
|
|
|
|
def attested(credential: object, origin: str) -> dict:
|
|
"""Args:
|
|
credential: {"id", "attestationObject", "clientDataJSON"} as the
|
|
settings panel encodes the navigator.credentials.create answer.
|
|
origin: the only origin the registration may come from.
|
|
|
|
Returns:
|
|
The entry to pin: {"id", "alg", "spki"}, the key as base64url DER.
|
|
"""
|
|
if not isinstance(credential, dict) or set(credential) != {"id", "attestationObject", "clientDataJSON"}:
|
|
raise Refused("the credential needs exactly id, attestationObject and clientDataJSON")
|
|
raw_id = b64decode(credential["id"], "id")
|
|
client_data(b64decode(credential["clientDataJSON"], "clientDataJSON"), "webauthn.create", origin)
|
|
blob = b64decode(credential["attestationObject"], "attestationObject")
|
|
attestation, end = cbor(blob, 0, 0)
|
|
if end != len(blob) or not isinstance(attestation, dict) or not isinstance(attestation.get("authData"), bytes):
|
|
raise Refused("the attestationObject holds no authData")
|
|
data = attestation["authData"]
|
|
if not rp_flags(data, 55) & FLAG_AT:
|
|
raise Refused("the registration carries no attested credential")
|
|
size = int.from_bytes(data[53:55], "big")
|
|
if data[55 : 55 + size] != raw_id:
|
|
raise Refused("the attested credential id differs from the id")
|
|
key, _ = cbor(data, 55 + size, 0)
|
|
alg, spki = cose_key(key)
|
|
return {"id": b64encode(raw_id), "alg": alg, "spki": b64encode(spki)}
|
|
|
|
|
|
def by_library(alg: int, spki: bytes, data: bytes, signature: bytes) -> bool:
|
|
try:
|
|
key = serialization.load_der_public_key(spki)
|
|
if alg == -7 and isinstance(key, ec.EllipticCurvePublicKey) and isinstance(key.curve, ec.SECP256R1):
|
|
key.verify(signature, data, ec.ECDSA(hashes.SHA256()))
|
|
elif alg == -8 and isinstance(key, ed25519.Ed25519PublicKey):
|
|
key.verify(signature, data)
|
|
elif alg == -257 and isinstance(key, rsa.RSAPublicKey):
|
|
key.verify(signature, data, padding.PKCS1v15(), hashes.SHA256())
|
|
else:
|
|
return False
|
|
except (InvalidSignature, UnsupportedAlgorithm, ValueError, TypeError):
|
|
return False
|
|
return True
|
|
|
|
|
|
def key_type(alg: int, spki: bytes) -> bool:
|
|
if alg == -7:
|
|
return len(spki) == len(EC_PREFIX) + 65 and spki.startswith(EC_PREFIX)
|
|
if alg == -8:
|
|
return len(spki) == len(ED_PREFIX) + 32 and spki.startswith(ED_PREFIX)
|
|
return alg == -257 and RSA_IDENTIFIER in spki[:24]
|
|
|
|
|
|
def by_openssl(alg: int, spki: bytes, data: bytes, signature: bytes) -> bool:
|
|
if not key_type(alg, spki):
|
|
return False
|
|
with tempfile.TemporaryDirectory(prefix="kvb-hub-verify-") as folder:
|
|
key, message, sealed = (Path(folder) / name for name in ("key.pem", "data.bin", "signature.bin"))
|
|
key.write_text(f"-----BEGIN PUBLIC KEY-----\n{base64.encodebytes(spki).decode('ascii')}-----END PUBLIC KEY-----\n", encoding="ascii")
|
|
message.write_bytes(data)
|
|
sealed.write_bytes(signature)
|
|
if alg == -8:
|
|
argv = ["openssl", "pkeyutl", "-verify", "-pubin", "-inkey", str(key), "-rawin", "-in", str(message), "-sigfile", str(sealed)]
|
|
else:
|
|
argv = ["openssl", "dgst", "-sha256", "-verify", str(key), "-signature", str(sealed), str(message)]
|
|
try:
|
|
done = subprocess.run(argv, capture_output=True, timeout=TIMEOUT, check=False)
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
return False
|
|
return done.returncode == 0
|
|
|
|
|
|
def verify_signature(alg: int, spki: bytes, data: bytes, signature: bytes) -> bool:
|
|
if serialization is None:
|
|
return by_openssl(alg, spki, data, signature)
|
|
return by_library(alg, spki, data, signature)
|
|
|
|
|
|
def verify_assertion(assertion: object, payload: dict, credentials: list[dict], origin: str) -> int:
|
|
"""Args:
|
|
assertion: {"id", "clientDataJSON", "authenticatorData", "signature"}
|
|
from navigator.credentials.get, base64url.
|
|
payload: the payload whose challenge the operator signed.
|
|
credentials: the pinned keys.
|
|
origin: the only origin the signature may come from.
|
|
|
|
Returns:
|
|
The authenticator's signature counter.
|
|
"""
|
|
if not isinstance(assertion, dict) or not all(isinstance(assertion.get(name), str) for name in ASSERTION_KEYS):
|
|
raise Refused("the assertion is incomplete")
|
|
credential = next((entry for entry in credentials if entry["id"] == assertion["id"]), None)
|
|
if credential is None:
|
|
raise Refused("the signing key is not pinned")
|
|
raw = b64decode(assertion["clientDataJSON"], "clientDataJSON")
|
|
if client_data(raw, "webauthn.get", origin).get("challenge") != challenge(payload):
|
|
raise Refused("the challenge is not the one of the payload")
|
|
data = b64decode(assertion["authenticatorData"], "authenticatorData")
|
|
rp_flags(data, 37)
|
|
signed = data + hashlib.sha256(raw).digest()
|
|
if not verify_signature(credential["alg"], b64decode(credential["spki"], "spki"), signed, b64decode(assertion["signature"], "signature")):
|
|
raise Refused("the signature does not verify with the pinned key")
|
|
return int.from_bytes(data[33:37], "big")
|