feat(orchestrator): rename kvb-hub to Agent Orchestrator with the phase 2 surface
Surface (requirement 001 phase 2): navigation Stream (Inbox, Regeln) / Agents / Artifacts with keys 1-3 and hash routes (#graph and #tree redirect), header with title, command palette and settings only, server-side badges (unsnoozed inbox, beats within 900 s, open jobs), a Mayday banner with "aufheben", four stream chips with agent:/art:/seit: search, Filter and time menus, the inbox in main, an agents toolbar (Funk an alle with Newsletter, Broadcast, Pan-Pan, Security, Mayday; Priorität; Tag/Nacht), Post or Call in the agent panel, Ambiente in the settings and read-aloud over the seven classes with Mayday locked. Rename (decision 9): hub/ is orchestrator/, the package kvb_orchestrator, the unit kvb-orchestrator-runner.service, scripts/orchestrator.sh, environment KVB_ORCHESTRATOR_*, make targets orchestrator-*, the MCP server, image and compose project kvb-orchestrator, the guard header X-Kvb-Orchestrator and the message marker [kvb-orchestrator msg <id>]. install runs migrate first: it stops the old container, removes its image and code, moves state, approvals and pinned keys, swaps the MCP registration, the PermissionRequest hook and the runner unit, and changes nothing on a second run (scratch-HOME test with stub docker, claude and systemctl). Saved views fall back to the old localStorage key once. MEDUSA acceptances carried to the renamed paths: 82 rows whose lines are unchanged (reason prefixed "renamed from <old path>, line unchanged"), and 7 rows whose text changed only in the product name, the package name, a temp prefix or a new class argument, each upheld by one kvb-think-criticize skeptic (approval.py:60, messaging.py:53, server.py:56, tests/hub_support.py:64, passkey.py:205, page-agent.js:75, language.py:46). Cost 101 %: the longer name adds 15 tokens of skill text; the operator approved it with the commit. 🎯 Fix: 92 % 💥 Break: 4 % 💸 Cost: 101 % ⏱️ Time: – 🛡️ Security: 100 % 💰 Value: ? 🏆 Score: 87 🚦 Gate: ⚠️ cost 101 % > 100: ask the operator before the commit Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
54f3725aa1
commit
6f940b4415
125 files changed
+1725
-782
No files matched your search
Executable
+113
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Args:
|
||||
pin --port <port> --approvers <file> --credential <blob>: pins the passkey
|
||||
that the Agent Orchestrator settings panel registered (the blob it shows) in
|
||||
<file>, written with mode 0600. Run it on the host, outside every
|
||||
sandbox, e.g. --approvers ~/.config/kvb-orchestrator/approvers.json.
|
||||
verify --port <port> --approvers <file> --replay <file> --review <id>
|
||||
--gate <pass|fail>: run in the worktree right before the commit;
|
||||
fetches the review's signature from the Agent Orchestrator on 127.0.0.1:<port>
|
||||
and checks it against the repository, worktree, branch and the tree
|
||||
git write-tree prints, as this process sees them.
|
||||
|
||||
Returns:
|
||||
pin: exit code 0 and the pinned credential on stdout; 1 and the reason
|
||||
on stderr when the blob is no valid registration.
|
||||
verify: exit code 0 when a pinned key signed this exact commit; 1 and
|
||||
the reason on stderr otherwise. Commit only on 0.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from hub_api import GUARD, SLACK, base_url, load
|
||||
from passkey import attested, b64decode
|
||||
from signing import ALGORITHMS, ID, Refused, origin_of, review_item
|
||||
from verifier import Trust, check, pin
|
||||
|
||||
GIT_TIMEOUT = 30
|
||||
|
||||
|
||||
def git(cwd: Path, args: list[str]) -> str:
|
||||
try:
|
||||
done = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, timeout=GIT_TIMEOUT, check=False)
|
||||
except (OSError, subprocess.TimeoutExpired) as error:
|
||||
raise Refused(f"git {args[0]} did not run: {error}") from error
|
||||
if done.returncode != 0:
|
||||
raise Refused(f"git {' '.join(args)} failed: {done.stderr.strip()}")
|
||||
return done.stdout.strip()
|
||||
|
||||
|
||||
def staged(cwd: Path, review_id: str) -> dict:
|
||||
repo = git(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"])
|
||||
worktree = git(cwd, ["rev-parse", "--show-toplevel"])
|
||||
branch = git(cwd, ["symbolic-ref", "--short", "HEAD"])
|
||||
return review_item(review_id, repo, worktree, branch, git(cwd, ["write-tree"]))
|
||||
|
||||
|
||||
def fetch(port: int, path: str) -> dict:
|
||||
request = urllib.request.Request(f"{base_url(port)}{path}", headers=GUARD)
|
||||
try:
|
||||
data = load(request, SLACK)
|
||||
except (OSError, ValueError) as error:
|
||||
raise Refused(f"the Agent Orchestrator did not answer: {error}") from error
|
||||
if not isinstance(data, dict):
|
||||
raise Refused("the Agent Orchestrator answered no object")
|
||||
return data
|
||||
|
||||
|
||||
def verify_commit(args: argparse.Namespace) -> str:
|
||||
review = fetch(args.port, f"/api/reviews/{args.review}")
|
||||
if review.get("status") != "approved":
|
||||
raise Refused(f"the review is {review.get('status')}, not approved")
|
||||
item = staged(Path.cwd(), args.review)
|
||||
check(review.get("proof"), item, args.gate, Trust(approvers=args.approvers, replay=args.replay, origin=origin_of(args.port)), time.time())
|
||||
return f"verified: the operator signed review {args.review}, tree {item['tree']} on {item['branch']} in {item['worktree']}"
|
||||
|
||||
|
||||
def pin_credential(args: argparse.Namespace) -> str:
|
||||
try:
|
||||
credential = json.loads(b64decode(args.credential, "credential"))
|
||||
except (ValueError, RecursionError) as error:
|
||||
raise Refused("the credential blob is no JSON") from error
|
||||
entry = attested(credential, origin_of(args.port))
|
||||
entries = pin(args.approvers, entry, time.time())
|
||||
return f"pinned {entry['id']} ({ALGORITHMS[entry['alg']]}) in {args.approvers}; {len(entries)} key(s) pinned"
|
||||
|
||||
|
||||
def review_id(text: str) -> str:
|
||||
if not ID.fullmatch(text):
|
||||
raise argparse.ArgumentTypeError("a review id is 16 hex digits")
|
||||
return text
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(prog="approval")
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
pinning = commands.add_parser("pin")
|
||||
verifying = commands.add_parser("verify")
|
||||
for sub in (pinning, verifying):
|
||||
sub.add_argument("--port", type=int, required=True)
|
||||
sub.add_argument("--approvers", type=Path, required=True)
|
||||
pinning.add_argument("--credential", required=True)
|
||||
verifying.add_argument("--replay", type=Path, required=True)
|
||||
verifying.add_argument("--review", type=review_id, required=True)
|
||||
verifying.add_argument("--gate", choices=("pass", "fail"), required=True)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
print(pin_credential(args) if args.command == "pin" else verify_commit(args))
|
||||
except (Refused, OSError) as error:
|
||||
print(f"refused: {error}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import urllib.request
|
||||
|
||||
SLACK = 15.0
|
||||
GUARD = {"X-Kvb-Orchestrator": "1"}
|
||||
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
return None
|
||||
|
||||
|
||||
OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect)
|
||||
|
||||
|
||||
def base_url(port: int) -> str:
|
||||
return f"http://127.0.0.1:{port}"
|
||||
|
||||
|
||||
def load(request: urllib.request.Request, timeout: float) -> object:
|
||||
with OPENER.open(request, timeout=timeout) as response:
|
||||
return json.loads(response.read())
|
||||
|
||||
|
||||
def call(request: urllib.request.Request, timeout: float) -> dict:
|
||||
data = load(request, timeout)
|
||||
if not isinstance(data, dict):
|
||||
raise TypeError("the hub answered no object")
|
||||
return data
|
||||
@@ -0,0 +1,251 @@
|
||||
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")
|
||||
P256_PRIME = 2**256 - 2**224 + 2**192 + 2**96 - 1
|
||||
P256_B = 0x5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B
|
||||
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 p256_point(x: object, y: object) -> bytes | None:
|
||||
# KeePassXC 2.7.12 drops leading zero bytes of x and y (Botan BigInt as hex), against RFC 9053; pad them back to 32.
|
||||
if not all(isinstance(value, bytes) and 1 <= len(value) <= 32 for value in (x, y)):
|
||||
return None
|
||||
left, right = int.from_bytes(x, "big"), int.from_bytes(y, "big")
|
||||
if left >= P256_PRIME or right >= P256_PRIME or (right * right - left**3 + 3 * left - P256_B) % P256_PRIME:
|
||||
return None
|
||||
return b"\x04" + left.to_bytes(32, "big") + right.to_bytes(32, "big")
|
||||
|
||||
|
||||
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 (point := p256_point(key.get(-2), key.get(-3))):
|
||||
return alg, EC_PREFIX + point
|
||||
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-orchestrator-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")
|
||||
Executable
+120
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Args:
|
||||
--port: port of the Agent Orchestrator on 127.0.0.1; the signature's origin is
|
||||
http://localhost:<port>.
|
||||
--timeout: seconds to wait for the operator's decision; keep it below the
|
||||
hook's timeout in settings.json.
|
||||
--approvers: the pinned passkeys, e.g. ~/.config/kvb-orchestrator/approvers.json.
|
||||
--replay: this hook's replay store, outside the hub's reach, e.g.
|
||||
~/.local/state/kvb-orchestrator-approvals/hook.json.
|
||||
stdin: the Claude Code PermissionRequest hook input.
|
||||
|
||||
Returns:
|
||||
stdout: the PermissionRequest decision JSON: allow only when a pinned
|
||||
passkey signed exactly this tool, command, cwd, session and ask id;
|
||||
deny when the operator denied in the Agent Orchestrator. Nothing on timeout,
|
||||
error, a missing or invalid signature, a value longer than the hub
|
||||
takes or a hub that is down, so Claude Code shows its own dialog.
|
||||
The exit code is 0.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
import hub_api
|
||||
import signing
|
||||
import verifier
|
||||
|
||||
POLL = 25.0
|
||||
NAME_LIMIT = 200
|
||||
COMMAND_LIMIT = 4000
|
||||
PATH_LIMIT = 4096
|
||||
LIMITS = {"tool": NAME_LIMIT, "command": COMMAND_LIMIT, "cwd": PATH_LIMIT, "session": NAME_LIMIT, "agent": NAME_LIMIT}
|
||||
DENIED = "The operator denied this in the Agent Orchestrator."
|
||||
|
||||
|
||||
def summary(tool_input: object) -> str:
|
||||
if isinstance(tool_input, dict):
|
||||
for key in ("command", "file_path", "url", "pattern", "path"):
|
||||
if isinstance(tool_input.get(key), str):
|
||||
return tool_input[key]
|
||||
return json.dumps(tool_input, ensure_ascii=False)
|
||||
|
||||
|
||||
def facts(hook: dict) -> dict:
|
||||
return {
|
||||
"tool": str(hook.get("tool_name") or "?"),
|
||||
"command": summary(hook.get("tool_input")),
|
||||
"cwd": str(hook.get("cwd") or ""),
|
||||
"session": str(hook.get("session_id") or ""),
|
||||
"agent": str(hook.get("agent_type") or ""),
|
||||
}
|
||||
|
||||
|
||||
def create(base: str, ask: dict, timeout: float) -> str:
|
||||
payload = {**ask, "timeout": timeout}
|
||||
headers = {"Content-Type": "application/json", **hub_api.GUARD}
|
||||
request = urllib.request.Request(f"{base}/api/asks", data=json.dumps(payload).encode(), headers=headers, method="POST")
|
||||
ask_id = hub_api.call(request, hub_api.SLACK)["id"]
|
||||
if not isinstance(ask_id, str) or not signing.ID.fullmatch(ask_id):
|
||||
raise ValueError("the hub answered no ask id")
|
||||
return ask_id
|
||||
|
||||
|
||||
def status(base: str, ask_id: str, wait: float) -> dict:
|
||||
answer = hub_api.call(urllib.request.Request(f"{base}/api/asks/{ask_id}?wait={wait:.1f}"), wait + hub_api.SLACK)
|
||||
if not isinstance(answer.get("status"), str):
|
||||
raise TypeError("the hub answered no status")
|
||||
return answer
|
||||
|
||||
|
||||
def decision(answer: dict, item: dict, trust: verifier.Trust) -> dict | None:
|
||||
if answer["status"] == "denied":
|
||||
return {"behavior": "deny", "message": DENIED}
|
||||
if answer["status"] != "allowed":
|
||||
return None
|
||||
try:
|
||||
verifier.check(answer.get("proof"), item, "", trust, time.time())
|
||||
except (signing.Refused, OSError) as error:
|
||||
print(f"kvb-orchestrator: not signed, Claude Code asks instead: {error}", file=sys.stderr)
|
||||
return None
|
||||
return {"behavior": "allow"}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(prog="permission_hook")
|
||||
parser.add_argument("--port", type=int, required=True)
|
||||
parser.add_argument("--timeout", type=float, required=True)
|
||||
parser.add_argument("--approvers", type=Path, required=True)
|
||||
parser.add_argument("--replay", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
base = hub_api.base_url(args.port)
|
||||
trust = verifier.Trust(approvers=args.approvers, replay=args.replay, origin=signing.origin_of(args.port))
|
||||
deadline = time.monotonic() + args.timeout
|
||||
try:
|
||||
hook = json.load(sys.stdin)
|
||||
ask = facts(hook)
|
||||
too_long = [name for name, limit in LIMITS.items() if len(ask[name]) > limit]
|
||||
if too_long:
|
||||
print(f"kvb-orchestrator: {', '.join(too_long)} too long to sign in full, Claude Code asks instead", file=sys.stderr)
|
||||
return 0
|
||||
ask_id = create(base, ask, args.timeout)
|
||||
answer = {"status": "pending"}
|
||||
while answer["status"] == "pending" and (left := deadline - time.monotonic()) > 0:
|
||||
answer = status(base, ask_id, min(left, POLL))
|
||||
except (OSError, ValueError, KeyError, TypeError, AttributeError):
|
||||
return 0
|
||||
chosen = decision(answer, signing.ask_item(ask_id, ask["tool"], ask["command"], ask["cwd"], ask["session"]), trust)
|
||||
if chosen is not None:
|
||||
print(json.dumps({"hookSpecificOutput": {"hookEventName": "PermissionRequest", "decision": chosen}}))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+160
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Args:
|
||||
--port: port of the Agent Orchestrator on 127.0.0.1; the signature's origin is
|
||||
http://localhost:<port>.
|
||||
--interval: seconds between two polls of the hub.
|
||||
--approvers: the pinned passkeys, e.g. ~/.config/kvb-orchestrator/approvers.json.
|
||||
--replay: the runner's replay store, outside the hub's reach, e.g.
|
||||
~/.local/state/kvb-orchestrator-approvals/runner.json.
|
||||
--once: handle the queued jobs once and exit.
|
||||
|
||||
Returns:
|
||||
Runs as the operator, outside any sandbox, every operator command a
|
||||
pinned passkey signed in the Agent Orchestrator: exactly the signed text, with bash
|
||||
in the signed directory, each signature once and before its expiry.
|
||||
Refuses every other job. Posts the last 200 output lines back while it
|
||||
runs and the exit code at its end. The exit code is 0.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import json
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import hub_api
|
||||
import signing
|
||||
import verifier
|
||||
|
||||
LINES = 200
|
||||
BEAT = 2.0
|
||||
REFUSED = "refused: no valid operator signature for this command"
|
||||
|
||||
|
||||
def call(base: str, path: str, payload: dict | None) -> dict:
|
||||
headers = dict(hub_api.GUARD)
|
||||
data = None
|
||||
if payload is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
data = json.dumps(payload).encode()
|
||||
request = urllib.request.Request(f"{base}{path}", data=data, headers=headers, method="GET" if payload is None else "POST")
|
||||
return hub_api.call(request, hub_api.SLACK)
|
||||
|
||||
|
||||
def collect(stream: object, tail: collections.deque, lock: threading.Lock) -> None:
|
||||
# The reader closes the pipe at EOF: a close in execute() would block on the buffer lock this read holds and SIGPIPE a command still running.
|
||||
with stream:
|
||||
for line in stream:
|
||||
with lock:
|
||||
tail.append(line)
|
||||
|
||||
|
||||
def refusal(job: dict, trust: verifier.Trust, now: float) -> str | None:
|
||||
"""Args:
|
||||
job: the claimed job with task, command, directory and proof.
|
||||
trust: the pinned keys, the runner's replay store and the origin.
|
||||
now: the current time in epoch seconds.
|
||||
|
||||
Returns:
|
||||
Why the job must not run, or None when a pinned key signed it.
|
||||
"""
|
||||
try:
|
||||
verifier.check(job.get("proof"), signing.operator_item(job["task"], job["command"], job["directory"]), "", trust, now)
|
||||
except (signing.Refused, OSError) as error:
|
||||
return f"{REFUSED}: {error}"
|
||||
return None
|
||||
|
||||
|
||||
def execute(job: dict, report: Callable[[dict], None]) -> int:
|
||||
"""Args:
|
||||
job: the claimed and verified job with command and directory.
|
||||
report: called with {"output", "code", "refused"}; code None while
|
||||
the command runs.
|
||||
|
||||
Returns:
|
||||
The exit code.
|
||||
"""
|
||||
tail: collections.deque = collections.deque(maxlen=LINES)
|
||||
lock = threading.Lock()
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
["bash", "-c", job["command"]],
|
||||
cwd=job["directory"],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
errors="replace",
|
||||
start_new_session=True,
|
||||
)
|
||||
except OSError as error:
|
||||
report({"output": f"could not start: {error}", "code": 127, "refused": False})
|
||||
return 127
|
||||
reader = threading.Thread(target=collect, args=(process.stdout, tail, lock), daemon=True)
|
||||
reader.start()
|
||||
while True:
|
||||
try:
|
||||
code = process.wait(BEAT)
|
||||
break
|
||||
except subprocess.TimeoutExpired:
|
||||
with lock:
|
||||
output = "".join(tail)
|
||||
report({"output": output, "code": None, "refused": False})
|
||||
reader.join()
|
||||
report({"output": "".join(tail), "code": code, "refused": False})
|
||||
return code
|
||||
|
||||
|
||||
def sender(base: str, job_id: str) -> Callable[[dict], None]:
|
||||
def send(result: dict) -> None:
|
||||
try:
|
||||
call(base, f"/api/runner/{job_id}/report", result)
|
||||
except (OSError, ValueError, TypeError):
|
||||
return
|
||||
|
||||
return send
|
||||
|
||||
|
||||
def cycle(base: str, trust: verifier.Trust) -> None:
|
||||
for job in call(base, "/api/runner/jobs", None)["jobs"]:
|
||||
try:
|
||||
claimed = call(base, f"/api/runner/{job['id']}/claim", {})
|
||||
except urllib.error.HTTPError:
|
||||
continue
|
||||
send = sender(base, claimed["id"])
|
||||
reason = refusal(claimed, trust, time.time())
|
||||
if reason is None:
|
||||
execute(claimed, send)
|
||||
else:
|
||||
send({"output": reason, "code": None, "refused": True})
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(prog="runner")
|
||||
parser.add_argument("--port", type=int, required=True)
|
||||
parser.add_argument("--interval", type=float, required=True)
|
||||
parser.add_argument("--approvers", type=Path, required=True)
|
||||
parser.add_argument("--replay", type=Path, required=True)
|
||||
parser.add_argument("--once", action="store_true")
|
||||
args = parser.parse_args()
|
||||
base = hub_api.base_url(args.port)
|
||||
trust = verifier.Trust(approvers=args.approvers, replay=args.replay, origin=signing.origin_of(args.port))
|
||||
while True:
|
||||
try:
|
||||
cycle(base, trust)
|
||||
except (OSError, ValueError, KeyError, TypeError):
|
||||
pass
|
||||
if args.once:
|
||||
return 0
|
||||
time.sleep(args.interval)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,142 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import fnmatch
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
RP_ID = "localhost"
|
||||
VERSION = 1
|
||||
LIFETIME = 900
|
||||
RULE_LIFETIME = 366 * 86400
|
||||
ALGORITHMS = {-7: "ES256", -8: "EdDSA", -257: "RS256"}
|
||||
HASH = re.compile(r"[0-9a-f]{64}")
|
||||
NONCE = re.compile(r"[0-9a-f]{32}")
|
||||
ID = re.compile(r"[0-9a-f]{16}")
|
||||
CHAINED = re.compile(r"[;&|`$<>()\n\r]")
|
||||
APPROVAL_KEYS = {"v", "kind", "items", "nonce", "expires"}
|
||||
RULE_KEYS = {"v", "kind", "id", "kinds", "tool", "pattern", "scope", "expires"}
|
||||
WARNINGS = (
|
||||
("löscht rekursiv", re.compile(r"\brm\s+(?:-\S+\s+)*-\S*[rR]")),
|
||||
("erzwingt", re.compile(r"--force\b|\bpush\b.*\s-f\b")),
|
||||
("pusht", re.compile(r"\bgit\b.*\bpush\b")),
|
||||
("root", re.compile(r"\bsudo\b|\bdoas\b")),
|
||||
("Pipe in eine Shell", re.compile(r"\|\s*(?:sudo\s+)?(?:ba|z|k|da)?sh\b")),
|
||||
("verwirft Änderungen", re.compile(r"\bgit\b.*\b(?:reset\s+--hard|clean\s+-\S*f)")),
|
||||
("Docker löscht", re.compile(r"\bdocker\s+(?:system\s+prune|volume\s+(?:rm|prune)|rmi?\b)")),
|
||||
)
|
||||
|
||||
|
||||
class Refused(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def origin_of(port: int) -> str:
|
||||
return f"http://{RP_ID}:{port}"
|
||||
|
||||
|
||||
def b64encode(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def canonical(value: object) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True, allow_nan=False).encode("ascii")
|
||||
|
||||
|
||||
def challenge(payload: dict) -> str:
|
||||
return b64encode(hashlib.sha256(canonical(payload)).digest())
|
||||
|
||||
|
||||
def item_hash(item: dict) -> str:
|
||||
return hashlib.sha256(canonical(item)).hexdigest()
|
||||
|
||||
|
||||
def ask_item(ask_id: str, tool: str, command: str, cwd: str, session: str) -> dict:
|
||||
return {"kind": "ask", "id": ask_id, "tool": tool, "command": command, "cwd": cwd, "session": session}
|
||||
|
||||
|
||||
def review_item(review_id: str, repo: str, worktree: str, branch: str, tree: str) -> dict:
|
||||
return {"kind": "review", "id": review_id, "repo": repo, "worktree": worktree, "branch": branch, "tree": tree}
|
||||
|
||||
|
||||
def operator_item(task: str, command: str, directory: str) -> dict:
|
||||
return {"kind": "operator", "id": task, "command": command, "directory": directory}
|
||||
|
||||
|
||||
def approval_payload(hashes: list[str], nonce: str, expires: int) -> dict:
|
||||
return {"v": VERSION, "kind": "approve", "items": sorted(set(hashes)), "nonce": nonce, "expires": expires}
|
||||
|
||||
|
||||
def rule_payload(rule_id: str, kinds: list[str], tool: str, pattern: str, scope: str, expires: int) -> dict:
|
||||
return {"v": VERSION, "kind": "rule", "id": rule_id, "kinds": kinds, "tool": tool, "pattern": pattern, "scope": scope, "expires": expires}
|
||||
|
||||
|
||||
def warnings(command: str) -> list[str]:
|
||||
return [label for label, pattern in WARNINGS if pattern.search(command)]
|
||||
|
||||
|
||||
def expiry(value: object, now: float, cap: int) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise Refused("the expiry is no whole number of epoch seconds")
|
||||
if value <= now:
|
||||
raise Refused("the signature has expired")
|
||||
if value > now + cap:
|
||||
raise Refused("the expiry lies too far ahead")
|
||||
return value
|
||||
|
||||
|
||||
def checked_approval(payload: object, now: float) -> dict:
|
||||
if not isinstance(payload, dict) or set(payload) != APPROVAL_KEYS or payload["v"] != VERSION or payload["kind"] != "approve":
|
||||
raise Refused("the payload is no approval")
|
||||
items = payload["items"]
|
||||
if not isinstance(items, list) or not items or not all(isinstance(entry, str) and HASH.fullmatch(entry) for entry in items):
|
||||
raise Refused("the approval lists no item hashes")
|
||||
if not isinstance(payload["nonce"], str) or not NONCE.fullmatch(payload["nonce"]):
|
||||
raise Refused("the approval holds no nonce")
|
||||
expiry(payload["expires"], now, LIFETIME)
|
||||
return payload
|
||||
|
||||
|
||||
def checked_rule(payload: object, now: float) -> dict:
|
||||
if not isinstance(payload, dict) or set(payload) != RULE_KEYS or payload["v"] != VERSION or payload["kind"] != "rule":
|
||||
raise Refused("the payload is no rule")
|
||||
if not isinstance(payload["id"], str) or not ID.fullmatch(payload["id"]):
|
||||
raise Refused("the rule holds no id")
|
||||
kinds = payload["kinds"]
|
||||
if not isinstance(kinds, list) or not kinds or not all(isinstance(kind, str) for kind in kinds):
|
||||
raise Refused("the rule names no kinds")
|
||||
if not isinstance(payload["tool"], str) or not all(isinstance(payload[name], str) and payload[name] for name in ("pattern", "scope")):
|
||||
raise Refused("the rule needs a tool text, a pattern and a scope")
|
||||
expiry(payload["expires"], now, RULE_LIFETIME)
|
||||
return payload
|
||||
|
||||
|
||||
def rule_allows(rule: dict, item: dict, gate: str, now: float) -> bool:
|
||||
if rule["expires"] <= now or item["kind"] not in rule["kinds"]:
|
||||
return False
|
||||
if item["kind"] == "ask":
|
||||
named = fnmatch.fnmatchcase(item["tool"], rule["tool"]) and fnmatch.fnmatchcase(item["cwd"], rule["scope"])
|
||||
plain = not CHAINED.search(item["command"]) and not warnings(item["command"])
|
||||
return named and plain and fnmatch.fnmatchcase(item["command"], rule["pattern"])
|
||||
if item["kind"] == "review":
|
||||
return gate == "pass" and fnmatch.fnmatchcase(item["branch"], rule["pattern"]) and fnmatch.fnmatchcase(item["worktree"], rule["scope"])
|
||||
return False
|
||||
|
||||
|
||||
def load_pinned(path: Path) -> list[dict]:
|
||||
try:
|
||||
mode = path.stat().st_mode
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
except (OSError, ValueError, RecursionError) as error:
|
||||
raise Refused(f"the pinned keys in {path} are unreadable") from error
|
||||
if mode & 0o022:
|
||||
raise Refused(f"{path} is writable by others")
|
||||
entries = data.get("credentials") if isinstance(data, dict) else None
|
||||
shaped = isinstance(entries, list) and all(isinstance(entry, dict) and isinstance(entry.get("id"), str) and entry.get("alg") in ALGORITHMS and isinstance(entry.get("spki"), str) for entry in entries)
|
||||
if not shaped:
|
||||
raise Refused(f"the pinned keys in {path} are malformed")
|
||||
return entries
|
||||
@@ -0,0 +1,124 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
|
||||
from passkey import verify_assertion
|
||||
from signing import (
|
||||
Refused,
|
||||
checked_approval,
|
||||
checked_rule,
|
||||
item_hash,
|
||||
load_pinned,
|
||||
rule_allows,
|
||||
)
|
||||
|
||||
|
||||
class Trust(NamedTuple):
|
||||
approvers: Path
|
||||
replay: Path
|
||||
origin: str
|
||||
|
||||
|
||||
def pinned(path: Path) -> list[dict]:
|
||||
entries = load_pinned(path)
|
||||
if not entries:
|
||||
raise Refused("no key is pinned")
|
||||
return entries
|
||||
|
||||
|
||||
def write_private(path: Path, data: dict) -> None:
|
||||
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
descriptor, name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}-")
|
||||
try:
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
||||
json.dump(data, handle, indent=1, sort_keys=True)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(name, path)
|
||||
except OSError:
|
||||
Path(name).unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def pin(path: Path, entry: dict, now: float) -> list[dict]:
|
||||
stamp = datetime.datetime.fromtimestamp(now, datetime.UTC).isoformat(timespec="seconds")
|
||||
entries = [old for old in load_pinned(path) if old["id"] != entry["id"]] + [{**entry, "pinned": stamp}]
|
||||
write_private(path, {"credentials": entries})
|
||||
return entries
|
||||
|
||||
|
||||
def read_store(store: Path) -> dict:
|
||||
try:
|
||||
data = json.loads(store.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
return {"used": {}, "counts": {}}
|
||||
except (OSError, ValueError, RecursionError) as error:
|
||||
raise Refused(f"the replay store {store} is unreadable") from error
|
||||
counts = data.get("counts") if isinstance(data, dict) else None
|
||||
shaped = isinstance(counts, dict) and all(isinstance(last, dict) and isinstance(last.get("count"), int) and isinstance(last.get("nonce"), str) for last in counts.values())
|
||||
if not shaped or not isinstance(data.get("used"), dict):
|
||||
raise Refused(f"the replay store {store} is malformed")
|
||||
return data
|
||||
|
||||
|
||||
def consume(store: Path, key: str, expires: int, credential: str, count: int, nonce: str, now: float) -> None:
|
||||
"""Args:
|
||||
store: the executor's own replay store, outside the hub's reach.
|
||||
key: the one-time id, nonce and item hash.
|
||||
expires: until when the id must stay known.
|
||||
credential: the id of the key that signed.
|
||||
count: its signature counter; 0 when the authenticator keeps none.
|
||||
nonce: the payload nonce; one bulk signature carries one counter.
|
||||
now: the current time in epoch seconds.
|
||||
"""
|
||||
store.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
descriptor = os.open(store.with_name(f"{store.name}.lock"), os.O_RDWR | os.O_CREAT, 0o600)
|
||||
try:
|
||||
fcntl.flock(descriptor, fcntl.LOCK_EX)
|
||||
state = read_store(store)
|
||||
used = {name: until for name, until in state["used"].items() if isinstance(until, int | float) and until > now}
|
||||
if key in used:
|
||||
raise Refused("this signature was already used for this item")
|
||||
last = state["counts"].get(credential)
|
||||
if last is not None and not (count > last["count"] or (count == last["count"] and (count == 0 or nonce == last["nonce"]))):
|
||||
raise Refused("the signature counter did not grow: a cloned authenticator or a replay")
|
||||
write_private(store, {"used": {**used, key: expires}, "counts": {**state["counts"], credential: {"count": count, "nonce": nonce}}})
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def check(proof: object, item: dict, gate: str, trust: Trust, now: float) -> None:
|
||||
"""Args:
|
||||
proof: {"payload", "assertion"} the Agent Orchestrator relayed for the item.
|
||||
item: the executor's own item, built from what it is about to do.
|
||||
gate: pass or fail as the executor itself saw it; a rule approves a
|
||||
review on pass only.
|
||||
trust: the pinned keys, the executor's replay store and the origin.
|
||||
now: the current time in epoch seconds.
|
||||
|
||||
Raises:
|
||||
Refused with the reason, unless a pinned key signed an unexpired
|
||||
approval that lists the item and is used here for the first time, or
|
||||
an unexpired rule whose pattern the item matches.
|
||||
"""
|
||||
if not isinstance(proof, dict) or set(proof) != {"payload", "assertion"}:
|
||||
raise Refused("no signature")
|
||||
payload = proof["payload"]
|
||||
if isinstance(payload, dict) and payload.get("kind") == "rule":
|
||||
checked_rule(payload, now)
|
||||
verify_assertion(proof["assertion"], payload, pinned(trust.approvers), trust.origin)
|
||||
if not rule_allows(payload, item, gate, now):
|
||||
raise Refused("the signed rule does not match this item")
|
||||
return
|
||||
checked_approval(payload, now)
|
||||
count = verify_assertion(proof["assertion"], payload, pinned(trust.approvers), trust.origin)
|
||||
digest = item_hash(item)
|
||||
if digest not in payload["items"]:
|
||||
raise Refused("the signature does not list this item")
|
||||
consume(trust.replay, f"{payload['nonce']}:{digest}", payload["expires"], proof["assertion"]["id"], count, payload["nonce"], now)
|
||||
Reference in new issue
Block a user