mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-05-08 02:16:41 +02:00
566 lines
20 KiB
Python
566 lines
20 KiB
Python
import asyncio
|
|
import hashlib
|
|
import hmac
|
|
import logging
|
|
import secrets
|
|
import time
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, Depends, Request
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from auth import (
|
|
_is_debug_test_request,
|
|
_scoped_view_authenticated,
|
|
_verify_peer_push_hmac,
|
|
require_admin,
|
|
)
|
|
from limiter import limiter
|
|
from services.config import get_settings
|
|
from services.mesh.mesh_compatibility import (
|
|
LEGACY_AGENT_ID_LOOKUP_TARGET,
|
|
legacy_agent_id_lookup_blocked,
|
|
record_legacy_agent_id_lookup,
|
|
sunset_target_label,
|
|
)
|
|
from services.mesh.mesh_signed_events import (
|
|
MeshWriteExemption,
|
|
SignedWriteKind,
|
|
get_prepared_signed_write,
|
|
mesh_write_exempt,
|
|
requires_signed_write,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
_WARNED_LEGACY_DM_PUBKEY_LOOKUPS: set[str] = set()
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Local helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _safe_int(val, default=0):
|
|
try:
|
|
return int(val)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
|
|
|
|
def _warn_legacy_dm_pubkey_lookup(agent_id: str) -> None:
|
|
peer_id = str(agent_id or "").strip().lower()
|
|
if not peer_id or peer_id in _WARNED_LEGACY_DM_PUBKEY_LOOKUPS:
|
|
return
|
|
_WARNED_LEGACY_DM_PUBKEY_LOOKUPS.add(peer_id)
|
|
logger.warning(
|
|
"mesh legacy DH pubkey lookup used for %s via direct agent_id; prefer invite-scoped lookup handles before removal in %s",
|
|
peer_id,
|
|
sunset_target_label(LEGACY_AGENT_ID_LOOKUP_TARGET),
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Transition delegates: forward to main.py so test monkeypatches still work.
|
|
# These will move to a shared module once main.py routes are removed.
|
|
# ---------------------------------------------------------------------------
|
|
def _main_delegate(name):
|
|
def _wrapper(*a, **kw):
|
|
import main as _m
|
|
return getattr(_m, name)(*a, **kw)
|
|
_wrapper.__name__ = name
|
|
return _wrapper
|
|
|
|
|
|
_verify_signed_write = _main_delegate("_verify_signed_write")
|
|
_secure_dm_enabled = _main_delegate("_secure_dm_enabled")
|
|
_legacy_dm_get_allowed = _main_delegate("_legacy_dm_get_allowed")
|
|
_rns_private_dm_ready = _main_delegate("_rns_private_dm_ready")
|
|
_anonymous_dm_hidden_transport_enforced = _main_delegate("_anonymous_dm_hidden_transport_enforced")
|
|
_high_privacy_profile_enabled = _main_delegate("_high_privacy_profile_enabled")
|
|
_dm_send_from_signed_request = _main_delegate("_dm_send_from_signed_request")
|
|
_dm_poll_secure_from_signed_request = _main_delegate("_dm_poll_secure_from_signed_request")
|
|
_dm_count_secure_from_signed_request = _main_delegate("_dm_count_secure_from_signed_request")
|
|
_validate_private_signed_sequence = _main_delegate("_validate_private_signed_sequence")
|
|
|
|
|
|
def _signed_body(request: Request) -> dict[str, Any]:
|
|
prepared = get_prepared_signed_write(request)
|
|
if prepared is None:
|
|
return {}
|
|
return dict(prepared.body)
|
|
|
|
|
|
async def _maybe_apply_dm_relay_jitter() -> None:
|
|
if not _high_privacy_profile_enabled():
|
|
return
|
|
await asyncio.sleep((50 + secrets.randbelow(451)) / 1000.0)
|
|
|
|
|
|
_REQUEST_V2_REDUCED_VERSION = "request-v2-reduced-v3"
|
|
_REQUEST_V2_RECOVERY_STATES = {"pending", "verified", "failed"}
|
|
|
|
|
|
def _is_canonical_reduced_request_message(message: dict[str, Any]) -> bool:
|
|
item = dict(message or {})
|
|
return (
|
|
str(item.get("delivery_class", "") or "").strip().lower() == "request"
|
|
and str(item.get("request_contract_version", "") or "").strip()
|
|
== _REQUEST_V2_REDUCED_VERSION
|
|
and item.get("sender_recovery_required") is True
|
|
)
|
|
|
|
|
|
def _annotate_request_recovery_message(message: dict[str, Any]) -> dict[str, Any]:
|
|
item = dict(message or {})
|
|
delivery_class = str(item.get("delivery_class", "") or "").strip().lower()
|
|
sender_id = str(item.get("sender_id", "") or "").strip()
|
|
sender_seal = str(item.get("sender_seal", "") or "").strip()
|
|
sender_is_blinded = sender_id.startswith("sealed:") or sender_id.startswith("sender_token:")
|
|
if delivery_class != "request" or not sender_is_blinded or not sender_seal.startswith("v3:"):
|
|
return item
|
|
if not str(item.get("request_contract_version", "") or "").strip():
|
|
item["request_contract_version"] = _REQUEST_V2_REDUCED_VERSION
|
|
item["sender_recovery_required"] = True
|
|
state = str(item.get("sender_recovery_state", "") or "").strip().lower()
|
|
if state not in _REQUEST_V2_RECOVERY_STATES:
|
|
state = "pending"
|
|
item["sender_recovery_state"] = state
|
|
return item
|
|
|
|
|
|
def _annotate_request_recovery_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
return [_annotate_request_recovery_message(message) for message in (messages or [])]
|
|
|
|
|
|
def _request_duplicate_authority_rank(message: dict[str, Any]) -> int:
|
|
item = dict(message or {})
|
|
if str(item.get("delivery_class", "") or "").strip().lower() != "request":
|
|
return 0
|
|
if _is_canonical_reduced_request_message(item):
|
|
return 3
|
|
sender_id = str(item.get("sender_id", "") or "").strip()
|
|
if sender_id.startswith("sealed:") or sender_id.startswith("sender_token:"):
|
|
return 1
|
|
if sender_id:
|
|
return 2
|
|
return 0
|
|
|
|
|
|
def _request_duplicate_recovery_rank(message: dict[str, Any]) -> int:
|
|
if not _is_canonical_reduced_request_message(message):
|
|
return 0
|
|
state = str(dict(message or {}).get("sender_recovery_state", "") or "").strip().lower()
|
|
if state == "verified":
|
|
return 2
|
|
if state == "pending":
|
|
return 1
|
|
return 0
|
|
|
|
|
|
def _poll_duplicate_source_rank(source: str) -> int:
|
|
normalized = str(source or "").strip().lower()
|
|
if normalized == "relay":
|
|
return 2
|
|
if normalized == "reticulum":
|
|
return 1
|
|
return 0
|
|
|
|
|
|
def _should_replace_dm_poll_duplicate(
|
|
existing: dict[str, Any],
|
|
existing_source: str,
|
|
candidate: dict[str, Any],
|
|
candidate_source: str,
|
|
) -> bool:
|
|
candidate_authority = _request_duplicate_authority_rank(candidate)
|
|
existing_authority = _request_duplicate_authority_rank(existing)
|
|
if candidate_authority != existing_authority:
|
|
return candidate_authority > existing_authority
|
|
|
|
candidate_recovery = _request_duplicate_recovery_rank(candidate)
|
|
existing_recovery = _request_duplicate_recovery_rank(existing)
|
|
if candidate_recovery != existing_recovery:
|
|
return candidate_recovery > existing_recovery
|
|
|
|
candidate_source_rank = _poll_duplicate_source_rank(candidate_source)
|
|
existing_source_rank = _poll_duplicate_source_rank(existing_source)
|
|
if candidate_source_rank != existing_source_rank:
|
|
return candidate_source_rank > existing_source_rank
|
|
|
|
try:
|
|
candidate_ts = float(candidate.get("timestamp", 0) or 0)
|
|
except Exception:
|
|
candidate_ts = 0.0
|
|
try:
|
|
existing_ts = float(existing.get("timestamp", 0) or 0)
|
|
except Exception:
|
|
existing_ts = 0.0
|
|
return candidate_ts > existing_ts
|
|
|
|
|
|
def _merge_dm_poll_messages(
|
|
relay_messages: list[dict[str, Any]],
|
|
direct_messages: list[dict[str, Any]],
|
|
) -> list[dict[str, Any]]:
|
|
merged: list[dict[str, Any]] = []
|
|
index_by_msg_id: dict[str, tuple[int, str]] = {}
|
|
|
|
def add_messages(items: list[dict[str, Any]], source: str) -> None:
|
|
for original in items or []:
|
|
item = dict(original or {})
|
|
msg_id = str(item.get("msg_id", "") or "").strip()
|
|
if not msg_id:
|
|
merged.append(item)
|
|
continue
|
|
existing = index_by_msg_id.get(msg_id)
|
|
if existing is None:
|
|
index_by_msg_id[msg_id] = (len(merged), source)
|
|
merged.append(item)
|
|
continue
|
|
index, existing_source = existing
|
|
if _should_replace_dm_poll_duplicate(merged[index], existing_source, item, source):
|
|
merged[index] = item
|
|
index_by_msg_id[msg_id] = (index, source)
|
|
|
|
add_messages(relay_messages, "relay")
|
|
add_messages(direct_messages, "reticulum")
|
|
return sorted(merged, key=lambda item: float(item.get("timestamp", 0) or 0))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Route handlers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.post("/api/mesh/dm/register")
|
|
@limiter.limit("10/minute")
|
|
@requires_signed_write(kind=SignedWriteKind.DM_REGISTER)
|
|
async def dm_register_key(request: Request):
|
|
"""Register a DH public key for encrypted DM key exchange."""
|
|
body = _signed_body(request)
|
|
agent_id = body.get("agent_id", "").strip()
|
|
dh_pub_key = body.get("dh_pub_key", "").strip()
|
|
dh_algo = body.get("dh_algo", "").strip()
|
|
timestamp = _safe_int(body.get("timestamp", 0) or 0)
|
|
public_key = body.get("public_key", "").strip()
|
|
public_key_algo = body.get("public_key_algo", "").strip()
|
|
signature = body.get("signature", "").strip()
|
|
sequence = _safe_int(body.get("sequence", 0) or 0)
|
|
protocol_version = body.get("protocol_version", "").strip()
|
|
if not agent_id or not dh_pub_key or not dh_algo or not timestamp:
|
|
return {"ok": False, "detail": "Missing agent_id, dh_pub_key, dh_algo, or timestamp"}
|
|
if dh_algo.upper() not in ("X25519", "ECDH_P256", "ECDH"):
|
|
return {"ok": False, "detail": "Unsupported dh_algo"}
|
|
now_ts = int(time.time())
|
|
if abs(timestamp - now_ts) > 7 * 86400:
|
|
return {"ok": False, "detail": "DH key timestamp is too far from current time"}
|
|
from services.mesh.mesh_dm_relay import dm_relay
|
|
|
|
try:
|
|
from services.mesh.mesh_reputation import reputation_ledger
|
|
|
|
reputation_ledger.register_node(agent_id, public_key, public_key_algo)
|
|
except Exception:
|
|
pass
|
|
|
|
accepted, detail, metadata = dm_relay.register_dh_key(
|
|
agent_id,
|
|
dh_pub_key,
|
|
dh_algo,
|
|
timestamp,
|
|
signature,
|
|
public_key,
|
|
public_key_algo,
|
|
protocol_version,
|
|
sequence,
|
|
)
|
|
if not accepted:
|
|
return {"ok": False, "detail": detail}
|
|
|
|
return {"ok": True, **(metadata or {})}
|
|
|
|
|
|
@router.get("/api/mesh/dm/pubkey")
|
|
@limiter.limit("30/minute")
|
|
async def dm_get_pubkey(request: Request, agent_id: str = "", lookup_token: str = ""):
|
|
import main as _m
|
|
|
|
return await _m.dm_get_pubkey(request, agent_id=agent_id, lookup_token=lookup_token)
|
|
|
|
|
|
@router.get("/api/mesh/dm/prekey-bundle")
|
|
@limiter.limit("30/minute")
|
|
async def dm_get_prekey_bundle(request: Request, agent_id: str = "", lookup_token: str = ""):
|
|
import main as _m
|
|
|
|
return await _m.dm_get_prekey_bundle(request, agent_id=agent_id, lookup_token=lookup_token)
|
|
|
|
|
|
@router.post("/api/mesh/dm/prekey-peer-lookup")
|
|
@limiter.limit("60/minute")
|
|
@mesh_write_exempt(MeshWriteExemption.PEER_GOSSIP)
|
|
async def dm_prekey_peer_lookup(request: Request):
|
|
"""Peer-authenticated invite lookup handle resolution.
|
|
|
|
This endpoint exists for private/bootstrap peers to import signed invites
|
|
without exposing a stable agent_id on the ordinary lookup surface. It only
|
|
accepts HMAC-authenticated peer calls and only resolves lookup_token.
|
|
"""
|
|
content_length = request.headers.get("content-length")
|
|
if content_length:
|
|
try:
|
|
if int(content_length) > 4096:
|
|
return JSONResponse(
|
|
status_code=413,
|
|
content={"ok": False, "detail": "Request body too large"},
|
|
)
|
|
except (TypeError, ValueError):
|
|
pass
|
|
body_bytes = await request.body()
|
|
if not _verify_peer_push_hmac(request, body_bytes):
|
|
return JSONResponse(
|
|
status_code=403,
|
|
content={"ok": False, "detail": "Invalid or missing peer HMAC"},
|
|
)
|
|
try:
|
|
import json
|
|
|
|
body = json.loads(body_bytes or b"{}")
|
|
except Exception:
|
|
return {"ok": False, "detail": "invalid json"}
|
|
lookup_token = str(dict(body or {}).get("lookup_token", "") or "").strip()
|
|
if not lookup_token:
|
|
return {"ok": False, "detail": "lookup_token required"}
|
|
from services.mesh.mesh_wormhole_prekey import fetch_dm_prekey_bundle
|
|
|
|
result = fetch_dm_prekey_bundle(
|
|
agent_id="",
|
|
lookup_token=lookup_token,
|
|
allow_peer_lookup=False,
|
|
)
|
|
if not result.get("ok"):
|
|
return {"ok": False, "detail": str(result.get("detail", "") or "Prekey bundle not found")}
|
|
safe = dict(result)
|
|
safe.pop("resolved_agent_id", None)
|
|
safe["lookup_mode"] = "invite_lookup_handle"
|
|
return safe
|
|
|
|
|
|
@router.post("/api/mesh/dm/send")
|
|
@limiter.limit("20/minute")
|
|
@requires_signed_write(kind=SignedWriteKind.DM_SEND)
|
|
async def dm_send(request: Request):
|
|
return await _dm_send_from_signed_request(request)
|
|
|
|
|
|
@router.post("/api/mesh/dm/poll")
|
|
@limiter.limit("30/minute")
|
|
@requires_signed_write(kind=SignedWriteKind.DM_POLL)
|
|
async def dm_poll_secure(request: Request):
|
|
return await _dm_poll_secure_from_signed_request(request)
|
|
|
|
|
|
@router.get("/api/mesh/dm/poll")
|
|
@limiter.limit("30/minute")
|
|
async def dm_poll(
|
|
request: Request,
|
|
agent_id: str = "",
|
|
agent_token: str = "",
|
|
agent_token_prev: str = "",
|
|
agent_tokens: str = "",
|
|
):
|
|
import main as _m
|
|
|
|
return await _m.dm_poll(
|
|
request,
|
|
agent_id=agent_id,
|
|
agent_token=agent_token,
|
|
agent_token_prev=agent_token_prev,
|
|
agent_tokens=agent_tokens,
|
|
)
|
|
|
|
|
|
@router.post("/api/mesh/dm/count")
|
|
@limiter.limit("60/minute")
|
|
@requires_signed_write(kind=SignedWriteKind.DM_COUNT)
|
|
async def dm_count_secure(request: Request):
|
|
return await _dm_count_secure_from_signed_request(request)
|
|
|
|
|
|
@router.get("/api/mesh/dm/count")
|
|
@limiter.limit("60/minute")
|
|
async def dm_count(
|
|
request: Request,
|
|
agent_id: str = "",
|
|
agent_token: str = "",
|
|
agent_token_prev: str = "",
|
|
agent_tokens: str = "",
|
|
):
|
|
import main as _m
|
|
|
|
return await _m.dm_count(
|
|
request,
|
|
agent_id=agent_id,
|
|
agent_token=agent_token,
|
|
agent_token_prev=agent_token_prev,
|
|
agent_tokens=agent_tokens,
|
|
)
|
|
|
|
|
|
@router.post("/api/mesh/dm/block")
|
|
@limiter.limit("10/minute")
|
|
@requires_signed_write(kind=SignedWriteKind.DM_BLOCK)
|
|
async def dm_block(request: Request):
|
|
"""Block or unblock a sender from DMing you."""
|
|
body = _signed_body(request)
|
|
agent_id = body.get("agent_id", "").strip()
|
|
blocked_id = body.get("blocked_id", "").strip()
|
|
action = body.get("action", "block").strip().lower()
|
|
public_key = body.get("public_key", "").strip()
|
|
public_key_algo = body.get("public_key_algo", "").strip()
|
|
signature = body.get("signature", "").strip()
|
|
sequence = _safe_int(body.get("sequence", 0) or 0)
|
|
protocol_version = body.get("protocol_version", "").strip()
|
|
if not agent_id or not blocked_id:
|
|
return {"ok": False, "detail": "Missing agent_id or blocked_id"}
|
|
from services.mesh.mesh_dm_relay import dm_relay
|
|
|
|
try:
|
|
from services.mesh.mesh_hashchain import infonet
|
|
|
|
ok_seq, seq_reason = _validate_private_signed_sequence(
|
|
infonet,
|
|
agent_id,
|
|
sequence,
|
|
domain=f"dm_block:{action}",
|
|
)
|
|
if not ok_seq:
|
|
return {"ok": False, "detail": seq_reason}
|
|
except Exception:
|
|
pass
|
|
|
|
if action == "unblock":
|
|
dm_relay.unblock(agent_id, blocked_id)
|
|
else:
|
|
dm_relay.block(agent_id, blocked_id)
|
|
return {"ok": True, "action": action, "blocked_id": blocked_id}
|
|
|
|
|
|
@router.post("/api/mesh/dm/witness")
|
|
@limiter.limit("20/minute")
|
|
@requires_signed_write(kind=SignedWriteKind.DM_WITNESS)
|
|
async def dm_key_witness(request: Request):
|
|
"""Record a lightweight witness for a DM key (dual-path spot-check)."""
|
|
body = _signed_body(request)
|
|
witness_id = body.get("witness_id", "").strip()
|
|
target_id = body.get("target_id", "").strip()
|
|
dh_pub_key = body.get("dh_pub_key", "").strip()
|
|
timestamp = _safe_int(body.get("timestamp", 0) or 0)
|
|
public_key = body.get("public_key", "").strip()
|
|
public_key_algo = body.get("public_key_algo", "").strip()
|
|
signature = body.get("signature", "").strip()
|
|
sequence = _safe_int(body.get("sequence", 0) or 0)
|
|
protocol_version = body.get("protocol_version", "").strip()
|
|
if not witness_id or not target_id or not dh_pub_key or not timestamp:
|
|
return {"ok": False, "detail": "Missing witness_id, target_id, dh_pub_key, or timestamp"}
|
|
now_ts = int(time.time())
|
|
if abs(timestamp - now_ts) > 7 * 86400:
|
|
return {"ok": False, "detail": "Witness timestamp is too far from current time"}
|
|
try:
|
|
from services.mesh.mesh_reputation import reputation_ledger
|
|
|
|
reputation_ledger.register_node(witness_id, public_key, public_key_algo)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
from services.mesh.mesh_hashchain import infonet
|
|
|
|
ok_seq, seq_reason = _validate_private_signed_sequence(
|
|
infonet,
|
|
witness_id,
|
|
sequence,
|
|
domain="dm_witness",
|
|
)
|
|
if not ok_seq:
|
|
return {"ok": False, "detail": seq_reason}
|
|
except Exception:
|
|
pass
|
|
from services.mesh.mesh_dm_relay import dm_relay
|
|
|
|
ok, reason = dm_relay.record_witness(witness_id, target_id, dh_pub_key, timestamp)
|
|
return {"ok": ok, "detail": reason}
|
|
|
|
|
|
@router.get("/api/mesh/dm/witness")
|
|
@limiter.limit("60/minute")
|
|
async def dm_key_witness_get(request: Request, target_id: str = "", dh_pub_key: str = ""):
|
|
"""Get witness counts for a target's DH key."""
|
|
if not target_id:
|
|
return {"ok": False, "detail": "Missing target_id"}
|
|
from services.mesh.mesh_dm_relay import dm_relay
|
|
|
|
witnesses = dm_relay.get_witnesses(target_id, dh_pub_key if dh_pub_key else None, limit=5)
|
|
response = {
|
|
"ok": True,
|
|
"count": len(witnesses),
|
|
}
|
|
if _scoped_view_authenticated(request, "mesh.audit"):
|
|
response["target_id"] = target_id
|
|
response["dh_pub_key"] = dh_pub_key or ""
|
|
response["witnesses"] = witnesses
|
|
return response
|
|
|
|
|
|
@router.post("/api/mesh/trust/vouch")
|
|
@limiter.limit("20/minute")
|
|
@requires_signed_write(kind=SignedWriteKind.TRUST_VOUCH)
|
|
async def trust_vouch(request: Request):
|
|
"""Record a trust vouch for a node (web-of-trust signal)."""
|
|
body = _signed_body(request)
|
|
voucher_id = body.get("voucher_id", "").strip()
|
|
target_id = body.get("target_id", "").strip()
|
|
note = body.get("note", "").strip()
|
|
timestamp = _safe_int(body.get("timestamp", 0) or 0)
|
|
public_key = body.get("public_key", "").strip()
|
|
public_key_algo = body.get("public_key_algo", "").strip()
|
|
signature = body.get("signature", "").strip()
|
|
sequence = _safe_int(body.get("sequence", 0) or 0)
|
|
protocol_version = body.get("protocol_version", "").strip()
|
|
if not voucher_id or not target_id or not timestamp:
|
|
return {"ok": False, "detail": "Missing voucher_id, target_id, or timestamp"}
|
|
now_ts = int(time.time())
|
|
if abs(timestamp - now_ts) > 7 * 86400:
|
|
return {"ok": False, "detail": "Vouch timestamp is too far from current time"}
|
|
try:
|
|
from services.mesh.mesh_reputation import reputation_ledger
|
|
from services.mesh.mesh_hashchain import infonet
|
|
|
|
reputation_ledger.register_node(voucher_id, public_key, public_key_algo)
|
|
ok_seq, seq_reason = _validate_private_signed_sequence(
|
|
infonet,
|
|
voucher_id,
|
|
sequence,
|
|
domain="trust_vouch",
|
|
)
|
|
if not ok_seq:
|
|
return {"ok": False, "detail": seq_reason}
|
|
ok, reason = reputation_ledger.add_vouch(voucher_id, target_id, note, timestamp)
|
|
return {"ok": ok, "detail": reason}
|
|
except Exception:
|
|
return {"ok": False, "detail": "Failed to record vouch"}
|
|
|
|
|
|
@router.get("/api/mesh/trust/vouches", dependencies=[Depends(require_admin)])
|
|
@limiter.limit("60/minute")
|
|
async def trust_vouches(request: Request, node_id: str = "", limit: int = 20):
|
|
"""Fetch latest vouches for a node."""
|
|
if not node_id:
|
|
return {"ok": False, "detail": "Missing node_id"}
|
|
try:
|
|
from services.mesh.mesh_reputation import reputation_ledger
|
|
|
|
vouches = reputation_ledger.get_vouches(node_id, limit=limit)
|
|
return {"ok": True, "node_id": node_id, "vouches": vouches, "count": len(vouches)}
|
|
except Exception:
|
|
return {"ok": False, "detail": "Failed to fetch vouches"}
|