Release armiger-dht-db-sync v2.1.0 (BEP-44/46 Byzantine-resilient RSI, multi-instance transfer proof, UDP wire harness, 45/45 red-team hardened)
Browse files- armiger_dht_db_sync.py +160 -50
- bep44_udp_wire_harness.py +642 -0
- csam_guard.py +52 -12
- test_bep44_udp_wire_e2e.py +351 -0
- test_dht_db_sync.py +2 -2
armiger_dht_db_sync.py
CHANGED
|
@@ -29,6 +29,7 @@ from __future__ import annotations
|
|
| 29 |
import dataclasses
|
| 30 |
import enum
|
| 31 |
import hashlib
|
|
|
|
| 32 |
import json
|
| 33 |
import os
|
| 34 |
import pathlib
|
|
@@ -167,16 +168,16 @@ class TorrentRecord:
|
|
| 167 |
def canonical_dict(self) -> dict[str, Any]:
|
| 168 |
"""Deterministic dictionary for serialization and hashing."""
|
| 169 |
return {
|
| 170 |
-
"category": self.category.lower(),
|
| 171 |
-
"content_sha256": self.content_sha256.lower(),
|
| 172 |
"created_at": int(self.created_at),
|
| 173 |
-
"epoch": self.epoch.lower(),
|
| 174 |
"file_count": int(self.file_count),
|
| 175 |
"file_list": sorted(self.file_list),
|
| 176 |
-
"infohash": self.infohash.lower(),
|
| 177 |
-
"language": self.language.lower(),
|
| 178 |
"leechers": int(self.leechers),
|
| 179 |
-
"quality_tier": self.quality_tier.lower(),
|
| 180 |
"safety_gate_passed": bool(self.safety_gate_passed),
|
| 181 |
"seeders": int(self.seeders),
|
| 182 |
"size_bytes": int(self.size_bytes),
|
|
@@ -238,7 +239,7 @@ class DHTShardManifest:
|
|
| 238 |
return dataclasses.replace(self, attestations=list(attestations))
|
| 239 |
|
| 240 |
def canonical_dict_without_sig(self) -> dict[str, Any]:
|
| 241 |
-
|
| 242 |
"bep46_infohash": self.bep46_infohash.lower(),
|
| 243 |
"ciphertext_sha256": self.ciphertext_sha256.lower(),
|
| 244 |
"compressed_size_bytes": int(self.compressed_size_bytes),
|
|
@@ -251,6 +252,9 @@ class DHTShardManifest:
|
|
| 251 |
"timestamp": int(self.timestamp),
|
| 252 |
"uncompressed_size_bytes": int(self.uncompressed_size_bytes),
|
| 253 |
}
|
|
|
|
|
|
|
|
|
|
| 254 |
|
| 255 |
def canonical_payload(self) -> bytes:
|
| 256 |
return json.dumps(self.canonical_dict_without_sig(), sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
@@ -260,8 +264,8 @@ class DHTShardManifest:
|
|
| 260 |
|
| 261 |
def bep44_target(self, salt: str | None = None) -> str:
|
| 262 |
"""Compute BEP-44 DHT target lookup key: SHA1(pubkey || salt)."""
|
| 263 |
-
pub_bytes = bytes.fromhex(self.root_pubkey_hex)
|
| 264 |
-
salt_bytes = (salt
|
| 265 |
return hashlib.sha1(pub_bytes + salt_bytes).hexdigest()
|
| 266 |
|
| 267 |
|
|
@@ -270,7 +274,9 @@ class DHTShardManifest:
|
|
| 270 |
# ---------------------------------------------------------------------------
|
| 271 |
|
| 272 |
class MerkleTree:
|
| 273 |
-
"""Binary Merkle tree over canonical record leaf hashes."""
|
|
|
|
|
|
|
| 274 |
|
| 275 |
def __init__(self, leaves: Sequence[bytes]) -> None:
|
| 276 |
self.leaves = list(leaves)
|
|
@@ -290,7 +296,7 @@ class MerkleTree:
|
|
| 290 |
next_level: list[bytes] = []
|
| 291 |
for i in range(0, len(current), 2):
|
| 292 |
left = current[i]
|
| 293 |
-
right = current[i + 1] if i + 1 < len(current) else
|
| 294 |
parent = hashlib.sha256(left + right).digest()
|
| 295 |
next_level.append(parent)
|
| 296 |
self.levels.append(next_level)
|
|
@@ -313,8 +319,9 @@ class MerkleTree:
|
|
| 313 |
is_right = (cur_idx % 2 == 0)
|
| 314 |
sib_idx = cur_idx + 1 if is_right else cur_idx - 1
|
| 315 |
if sib_idx >= len(level):
|
| 316 |
-
|
| 317 |
-
|
|
|
|
| 318 |
cur_idx //= 2
|
| 319 |
|
| 320 |
return proof
|
|
@@ -322,14 +329,31 @@ class MerkleTree:
|
|
| 322 |
@staticmethod
|
| 323 |
def verify_proof(leaf_hash: bytes, proof: Sequence[tuple[str, bool]], root_hex: str) -> bool:
|
| 324 |
"""Verify an inclusion proof against expected Merkle root."""
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 328 |
if is_right:
|
| 329 |
current = hashlib.sha256(current + sib).digest()
|
| 330 |
else:
|
| 331 |
current = hashlib.sha256(sib + current).digest()
|
| 332 |
-
return
|
| 333 |
|
| 334 |
|
| 335 |
# ---------------------------------------------------------------------------
|
|
@@ -521,16 +545,41 @@ def compress_payload(data: bytes) -> tuple[bytes, str]:
|
|
| 521 |
return zlib.compress(data, level=9), "zlib"
|
| 522 |
|
| 523 |
|
| 524 |
-
|
| 525 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 526 |
if codec == "zstd":
|
| 527 |
if _HAS_ZSTD:
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 532 |
elif codec == "zlib":
|
| 533 |
-
return
|
| 534 |
raise ValueError(f"Unsupported compression codec: {codec}")
|
| 535 |
|
| 536 |
|
|
@@ -645,7 +694,10 @@ class ShardDatabase:
|
|
| 645 |
|
| 646 |
def insert_record(self, record: TorrentRecord) -> bool:
|
| 647 |
"""Insert a torrent record into the appropriate partitioned table."""
|
| 648 |
-
|
|
|
|
|
|
|
|
|
|
| 649 |
if is_adult and not self.enable_xxx:
|
| 650 |
return False # Drop un-opted adult record
|
| 651 |
|
|
@@ -660,12 +712,12 @@ class ShardDatabase:
|
|
| 660 |
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
| 661 |
""",
|
| 662 |
(
|
| 663 |
-
record.infohash.lower(),
|
| 664 |
record.title,
|
| 665 |
-
|
| 666 |
-
record.language.lower(),
|
| 667 |
-
record.quality_tier.lower(),
|
| 668 |
-
record.epoch.lower(),
|
| 669 |
record.size_bytes,
|
| 670 |
record.file_count,
|
| 671 |
record.seeders,
|
|
@@ -684,16 +736,19 @@ class ShardDatabase:
|
|
| 684 |
main_rows: list[tuple[Any, ...]] = []
|
| 685 |
adult_rows: list[tuple[Any, ...]] = []
|
| 686 |
for record in records:
|
| 687 |
-
|
|
|
|
|
|
|
|
|
|
| 688 |
if is_adult and not self.enable_xxx:
|
| 689 |
continue
|
| 690 |
row = (
|
| 691 |
-
record.infohash.lower(),
|
| 692 |
record.title,
|
| 693 |
-
|
| 694 |
-
record.language.lower(),
|
| 695 |
-
record.quality_tier.lower(),
|
| 696 |
-
record.epoch.lower(),
|
| 697 |
record.size_bytes,
|
| 698 |
record.file_count,
|
| 699 |
record.seeders,
|
|
@@ -943,19 +998,30 @@ class QuorumVerifier:
|
|
| 943 |
4. Distinct ASNs >= min_distinct_asns
|
| 944 |
5. Each attestation Hybrid Ed25519 + WOTS+-SHAKE256 signature
|
| 945 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 946 |
# 1. Monotonic sequence check
|
| 947 |
if manifest.seq <= current_seq:
|
| 948 |
return False, f"stale_or_downgrade_sequence:{manifest.seq}<={current_seq}"
|
| 949 |
|
| 950 |
# 2. Root Ed25519 signature check
|
| 951 |
-
|
| 952 |
-
|
|
|
|
|
|
|
|
|
|
| 953 |
return False, f"untrusted_root_pubkey:{manifest.root_pubkey_hex}"
|
| 954 |
|
| 955 |
payload = manifest.canonical_payload()
|
| 956 |
try:
|
| 957 |
vk = Ed25519PublicKey.from_public_bytes(root_pub_bytes)
|
| 958 |
-
vk.verify(bytes.fromhex(manifest.ed25519_signature_hex), payload)
|
| 959 |
except (InvalidSignature, ValueError) as exc:
|
| 960 |
return False, f"invalid_root_ed25519_signature:{exc}"
|
| 961 |
|
|
@@ -979,16 +1045,48 @@ class QuorumVerifier:
|
|
| 979 |
manifest_digest = manifest.canonical_digest()
|
| 980 |
verified_asns: set[int] = set()
|
| 981 |
seen_node_ids: set[str] = set()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 982 |
|
| 983 |
for att in manifest.attestations:
|
| 984 |
-
|
| 985 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 986 |
continue
|
| 987 |
-
seen_node_ids.add(att.node_id.lower())
|
| 988 |
|
| 989 |
try:
|
| 990 |
-
node_vk = Ed25519PublicKey.from_public_bytes(bytes.fromhex(
|
| 991 |
-
node_vk.verify(bytes.fromhex(
|
| 992 |
except (InvalidSignature, ValueError):
|
| 993 |
continue
|
| 994 |
|
|
@@ -1138,10 +1236,11 @@ class ArmigerDHTDatabaseSync:
|
|
| 1138 |
ciphertext_sha = hashlib.sha256(ciphertext_bundle).hexdigest()
|
| 1139 |
bep46_infohash = hashlib.sha1(ciphertext_bundle).hexdigest()
|
| 1140 |
|
| 1141 |
-
# 3. Create manifest and sign with Ed25519 root private key
|
| 1142 |
sk = Ed25519PrivateKey.from_private_bytes(root_privkey)
|
| 1143 |
pk = sk.public_key()
|
| 1144 |
root_pub_bytes = pk.public_bytes(Encoding.Raw, PublicFormat.Raw)
|
|
|
|
| 1145 |
|
| 1146 |
manifest_dummy = DHTShardManifest(
|
| 1147 |
seq=seq,
|
|
@@ -1157,11 +1256,11 @@ class ArmigerDHTDatabaseSync:
|
|
| 1157 |
root_pubkey_hex=root_pub_bytes.hex(),
|
| 1158 |
ed25519_signature_hex="",
|
| 1159 |
attestations=list(attestations or []),
|
|
|
|
| 1160 |
)
|
| 1161 |
|
| 1162 |
payload_bytes = manifest_dummy.canonical_payload()
|
| 1163 |
sig = sk.sign(payload_bytes)
|
| 1164 |
-
pq_priv, pq_pub = WOTSPlusSHAKE256.derive_keypair_from_ed25519_priv(root_privkey)
|
| 1165 |
pq_sig = WOTSPlusSHAKE256.sign(pq_priv, payload_bytes)
|
| 1166 |
|
| 1167 |
manifest = DHTShardManifest(
|
|
@@ -1203,10 +1302,12 @@ class ArmigerDHTDatabaseSync:
|
|
| 1203 |
if not q_ok:
|
| 1204 |
return False, f"quorum_verification_failed:{q_msg}", 0
|
| 1205 |
|
| 1206 |
-
# 3. Content-addressed verification of ciphertext
|
| 1207 |
actual_sha = hashlib.sha256(ciphertext_bundle).hexdigest()
|
| 1208 |
if actual_sha.lower() != manifest.ciphertext_sha256.lower():
|
| 1209 |
return False, "ciphertext_sha256_mismatch", 0
|
|
|
|
|
|
|
| 1210 |
|
| 1211 |
# 4. Decrypt shard with AEAD
|
| 1212 |
key = ShardCryptoEngine.derive_shard_key(self.master_secret, manifest.shard_id, manifest.epoch)
|
|
@@ -1222,8 +1323,17 @@ class ArmigerDHTDatabaseSync:
|
|
| 1222 |
except (InvalidTag, ValueError) as exc:
|
| 1223 |
return False, f"aead_decryption_failed:{exc}", 0
|
| 1224 |
|
| 1225 |
-
# 5. Decompress records & verify Merkle root
|
| 1226 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1227 |
reconstructed_leaves = [r.leaf_hash() for r in sorted(records, key=lambda r: r.infohash.lower())]
|
| 1228 |
reconstructed_tree = MerkleTree(reconstructed_leaves)
|
| 1229 |
|
|
@@ -1289,7 +1399,7 @@ def create_mock_quorum_attestations(
|
|
| 1289 |
timestamp: int | None = None,
|
| 1290 |
) -> list[QuorumAttestation]:
|
| 1291 |
"""Generate cryptographically valid Hybrid Ed25519 + WOTS+-SHAKE256 QuorumAttestations from distinct ASNs."""
|
| 1292 |
-
ts =
|
| 1293 |
attestations: list[QuorumAttestation] = []
|
| 1294 |
for asn in distinct_asns:
|
| 1295 |
priv, pub = generate_ed25519_keypair()
|
|
|
|
| 29 |
import dataclasses
|
| 30 |
import enum
|
| 31 |
import hashlib
|
| 32 |
+
import hmac
|
| 33 |
import json
|
| 34 |
import os
|
| 35 |
import pathlib
|
|
|
|
| 168 |
def canonical_dict(self) -> dict[str, Any]:
|
| 169 |
"""Deterministic dictionary for serialization and hashing."""
|
| 170 |
return {
|
| 171 |
+
"category": self.category.strip().lower(),
|
| 172 |
+
"content_sha256": self.content_sha256.strip().lower(),
|
| 173 |
"created_at": int(self.created_at),
|
| 174 |
+
"epoch": self.epoch.strip().lower(),
|
| 175 |
"file_count": int(self.file_count),
|
| 176 |
"file_list": sorted(self.file_list),
|
| 177 |
+
"infohash": self.infohash.strip().lower(),
|
| 178 |
+
"language": self.language.strip().lower(),
|
| 179 |
"leechers": int(self.leechers),
|
| 180 |
+
"quality_tier": self.quality_tier.strip().lower(),
|
| 181 |
"safety_gate_passed": bool(self.safety_gate_passed),
|
| 182 |
"seeders": int(self.seeders),
|
| 183 |
"size_bytes": int(self.size_bytes),
|
|
|
|
| 239 |
return dataclasses.replace(self, attestations=list(attestations))
|
| 240 |
|
| 241 |
def canonical_dict_without_sig(self) -> dict[str, Any]:
|
| 242 |
+
d: dict[str, Any] = {
|
| 243 |
"bep46_infohash": self.bep46_infohash.lower(),
|
| 244 |
"ciphertext_sha256": self.ciphertext_sha256.lower(),
|
| 245 |
"compressed_size_bytes": int(self.compressed_size_bytes),
|
|
|
|
| 252 |
"timestamp": int(self.timestamp),
|
| 253 |
"uncompressed_size_bytes": int(self.uncompressed_size_bytes),
|
| 254 |
}
|
| 255 |
+
if self.root_pq_pubkey_hex:
|
| 256 |
+
d["root_pq_pubkey_hex"] = self.root_pq_pubkey_hex.strip().lower()
|
| 257 |
+
return d
|
| 258 |
|
| 259 |
def canonical_payload(self) -> bytes:
|
| 260 |
return json.dumps(self.canonical_dict_without_sig(), sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
|
|
| 264 |
|
| 265 |
def bep44_target(self, salt: str | None = None) -> str:
|
| 266 |
"""Compute BEP-44 DHT target lookup key: SHA1(pubkey || salt)."""
|
| 267 |
+
pub_bytes = bytes.fromhex(self.root_pubkey_hex.strip().lower())
|
| 268 |
+
salt_bytes = (salt.lower() if salt else f"armiger-shard:{self.shard_id.strip().lower()}").encode("utf-8")
|
| 269 |
return hashlib.sha1(pub_bytes + salt_bytes).hexdigest()
|
| 270 |
|
| 271 |
|
|
|
|
| 274 |
# ---------------------------------------------------------------------------
|
| 275 |
|
| 276 |
class MerkleTree:
|
| 277 |
+
"""Binary Merkle tree over canonical record leaf hashes with domain-separated odd-node padding."""
|
| 278 |
+
|
| 279 |
+
ODD_NODE_SENTINEL: bytes = hashlib.sha256(b"armiger-merkle-odd-node-sentinel-v1").digest()
|
| 280 |
|
| 281 |
def __init__(self, leaves: Sequence[bytes]) -> None:
|
| 282 |
self.leaves = list(leaves)
|
|
|
|
| 296 |
next_level: list[bytes] = []
|
| 297 |
for i in range(0, len(current), 2):
|
| 298 |
left = current[i]
|
| 299 |
+
right = current[i + 1] if i + 1 < len(current) else self.ODD_NODE_SENTINEL
|
| 300 |
parent = hashlib.sha256(left + right).digest()
|
| 301 |
next_level.append(parent)
|
| 302 |
self.levels.append(next_level)
|
|
|
|
| 319 |
is_right = (cur_idx % 2 == 0)
|
| 320 |
sib_idx = cur_idx + 1 if is_right else cur_idx - 1
|
| 321 |
if sib_idx >= len(level):
|
| 322 |
+
proof.append((self.ODD_NODE_SENTINEL.hex(), True))
|
| 323 |
+
else:
|
| 324 |
+
proof.append((level[sib_idx].hex(), is_right))
|
| 325 |
cur_idx //= 2
|
| 326 |
|
| 327 |
return proof
|
|
|
|
| 329 |
@staticmethod
|
| 330 |
def verify_proof(leaf_hash: bytes, proof: Sequence[tuple[str, bool]], root_hex: str) -> bool:
|
| 331 |
"""Verify an inclusion proof against expected Merkle root."""
|
| 332 |
+
if not isinstance(leaf_hash, (bytes, bytearray)) or len(leaf_hash) != 32:
|
| 333 |
+
return False
|
| 334 |
+
try:
|
| 335 |
+
expected_root = bytes.fromhex(str(root_hex).strip())
|
| 336 |
+
if len(expected_root) != 32:
|
| 337 |
+
return False
|
| 338 |
+
except (ValueError, TypeError):
|
| 339 |
+
return False
|
| 340 |
+
|
| 341 |
+
current = bytes(leaf_hash)
|
| 342 |
+
for item in proof:
|
| 343 |
+
if not isinstance(item, (tuple, list)) or len(item) != 2:
|
| 344 |
+
return False
|
| 345 |
+
sib_hex, is_right = item
|
| 346 |
+
try:
|
| 347 |
+
sib = bytes.fromhex(str(sib_hex).strip())
|
| 348 |
+
except (ValueError, TypeError):
|
| 349 |
+
return False
|
| 350 |
+
if len(sib) != 32:
|
| 351 |
+
return False
|
| 352 |
if is_right:
|
| 353 |
current = hashlib.sha256(current + sib).digest()
|
| 354 |
else:
|
| 355 |
current = hashlib.sha256(sib + current).digest()
|
| 356 |
+
return hmac.compare_digest(current, expected_root)
|
| 357 |
|
| 358 |
|
| 359 |
# ---------------------------------------------------------------------------
|
|
|
|
| 545 |
return zlib.compress(data, level=9), "zlib"
|
| 546 |
|
| 547 |
|
| 548 |
+
MAX_DECOMPRESSED_SHARD_BYTES = 4 * 1024 * 1024
|
| 549 |
+
MAX_DECOMPRESSION_RATIO = 50
|
| 550 |
+
|
| 551 |
+
|
| 552 |
+
def decompress_payload(
|
| 553 |
+
compressed: bytes,
|
| 554 |
+
codec: str = "zstd",
|
| 555 |
+
max_output_bytes: int = MAX_DECOMPRESSED_SHARD_BYTES,
|
| 556 |
+
max_ratio: int = MAX_DECOMPRESSION_RATIO,
|
| 557 |
+
) -> bytes:
|
| 558 |
+
"""Decompress payload using specified codec with strict zip-bomb size & ratio caps."""
|
| 559 |
+
if not compressed:
|
| 560 |
+
return b""
|
| 561 |
+
hard_cap = min(max_output_bytes, max(65536, len(compressed) * max_ratio))
|
| 562 |
+
|
| 563 |
+
def _zlib_bounded(data: bytes) -> bytes:
|
| 564 |
+
dobj = zlib.decompressobj()
|
| 565 |
+
out = dobj.decompress(data, hard_cap + 1)
|
| 566 |
+
if dobj.unconsumed_tail or len(out) > hard_cap:
|
| 567 |
+
return b""
|
| 568 |
+
return out
|
| 569 |
+
|
| 570 |
if codec == "zstd":
|
| 571 |
if _HAS_ZSTD:
|
| 572 |
+
try:
|
| 573 |
+
dctx = zstd.ZstdDecompressor()
|
| 574 |
+
out = dctx.decompress(compressed, max_output_size=hard_cap + 1)
|
| 575 |
+
if len(out) > hard_cap:
|
| 576 |
+
return b""
|
| 577 |
+
return out
|
| 578 |
+
except Exception:
|
| 579 |
+
return _zlib_bounded(compressed)
|
| 580 |
+
return _zlib_bounded(compressed)
|
| 581 |
elif codec == "zlib":
|
| 582 |
+
return _zlib_bounded(compressed)
|
| 583 |
raise ValueError(f"Unsupported compression codec: {codec}")
|
| 584 |
|
| 585 |
|
|
|
|
| 694 |
|
| 695 |
def insert_record(self, record: TorrentRecord) -> bool:
|
| 696 |
"""Insert a torrent record into the appropriate partitioned table."""
|
| 697 |
+
cat_clean = record.category.strip().lower()
|
| 698 |
+
if cat_clean not in ALL_CATEGORIES:
|
| 699 |
+
return False
|
| 700 |
+
is_adult = (cat_clean == CATEGORY_ADULT)
|
| 701 |
if is_adult and not self.enable_xxx:
|
| 702 |
return False # Drop un-opted adult record
|
| 703 |
|
|
|
|
| 712 |
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
| 713 |
""",
|
| 714 |
(
|
| 715 |
+
record.infohash.strip().lower(),
|
| 716 |
record.title,
|
| 717 |
+
cat_clean,
|
| 718 |
+
record.language.strip().lower(),
|
| 719 |
+
record.quality_tier.strip().lower(),
|
| 720 |
+
record.epoch.strip().lower(),
|
| 721 |
record.size_bytes,
|
| 722 |
record.file_count,
|
| 723 |
record.seeders,
|
|
|
|
| 736 |
main_rows: list[tuple[Any, ...]] = []
|
| 737 |
adult_rows: list[tuple[Any, ...]] = []
|
| 738 |
for record in records:
|
| 739 |
+
cat_clean = record.category.strip().lower()
|
| 740 |
+
if cat_clean not in ALL_CATEGORIES:
|
| 741 |
+
continue
|
| 742 |
+
is_adult = (cat_clean == CATEGORY_ADULT)
|
| 743 |
if is_adult and not self.enable_xxx:
|
| 744 |
continue
|
| 745 |
row = (
|
| 746 |
+
record.infohash.strip().lower(),
|
| 747 |
record.title,
|
| 748 |
+
cat_clean,
|
| 749 |
+
record.language.strip().lower(),
|
| 750 |
+
record.quality_tier.strip().lower(),
|
| 751 |
+
record.epoch.strip().lower(),
|
| 752 |
record.size_bytes,
|
| 753 |
record.file_count,
|
| 754 |
record.seeders,
|
|
|
|
| 998 |
4. Distinct ASNs >= min_distinct_asns
|
| 999 |
5. Each attestation Hybrid Ed25519 + WOTS+-SHAKE256 signature
|
| 1000 |
"""
|
| 1001 |
+
# 0. Policy & type sanity checks
|
| 1002 |
+
if isinstance(manifest.seq, bool) or not isinstance(manifest.seq, int) or manifest.seq < 0:
|
| 1003 |
+
return False, "invalid_manifest_seq_type"
|
| 1004 |
+
if self.min_quorum <= 0 or self.min_distinct_asns <= 0:
|
| 1005 |
+
return False, "invalid_quorum_policy_threshold"
|
| 1006 |
+
if not self.trusted_roots:
|
| 1007 |
+
return False, "no_trusted_root_pubkeys_configured"
|
| 1008 |
+
|
| 1009 |
# 1. Monotonic sequence check
|
| 1010 |
if manifest.seq <= current_seq:
|
| 1011 |
return False, f"stale_or_downgrade_sequence:{manifest.seq}<={current_seq}"
|
| 1012 |
|
| 1013 |
# 2. Root Ed25519 signature check
|
| 1014 |
+
try:
|
| 1015 |
+
root_pub_bytes = bytes.fromhex(manifest.root_pubkey_hex.strip().lower())
|
| 1016 |
+
except ValueError:
|
| 1017 |
+
return False, "invalid_root_pubkey_hex"
|
| 1018 |
+
if root_pub_bytes not in self.trusted_roots:
|
| 1019 |
return False, f"untrusted_root_pubkey:{manifest.root_pubkey_hex}"
|
| 1020 |
|
| 1021 |
payload = manifest.canonical_payload()
|
| 1022 |
try:
|
| 1023 |
vk = Ed25519PublicKey.from_public_bytes(root_pub_bytes)
|
| 1024 |
+
vk.verify(bytes.fromhex(manifest.ed25519_signature_hex.strip()), payload)
|
| 1025 |
except (InvalidSignature, ValueError) as exc:
|
| 1026 |
return False, f"invalid_root_ed25519_signature:{exc}"
|
| 1027 |
|
|
|
|
| 1045 |
manifest_digest = manifest.canonical_digest()
|
| 1046 |
verified_asns: set[int] = set()
|
| 1047 |
seen_node_ids: set[str] = set()
|
| 1048 |
+
seen_pubkeys: set[str] = set()
|
| 1049 |
+
seen_pq_pubkeys: set[str] = set()
|
| 1050 |
+
seen_sigs: set[str] = set()
|
| 1051 |
+
seen_ip_digests: set[str] = set()
|
| 1052 |
+
now = int(time.time())
|
| 1053 |
|
| 1054 |
for att in manifest.attestations:
|
| 1055 |
+
norm_node = str(att.node_id).strip().lower()
|
| 1056 |
+
norm_pk = str(att.pubkey_hex).strip().lower()
|
| 1057 |
+
norm_sig = str(att.signature_hex).strip().lower()
|
| 1058 |
+
norm_ip = str(att.ip_digest).strip().lower()
|
| 1059 |
+
|
| 1060 |
+
# Reject duplicate node_id, pubkey, signature, or IP digest Sybil replay
|
| 1061 |
+
if norm_pk in seen_pubkeys or norm_sig in seen_sigs:
|
| 1062 |
+
return False, f"sybil_duplicate_attestor_pubkey_or_signature:{norm_pk[:16]}"
|
| 1063 |
+
if norm_node in seen_node_ids or (norm_ip and norm_ip in seen_ip_digests):
|
| 1064 |
+
continue
|
| 1065 |
+
|
| 1066 |
+
seen_node_ids.add(norm_node)
|
| 1067 |
+
seen_pubkeys.add(norm_pk)
|
| 1068 |
+
seen_sigs.add(norm_sig)
|
| 1069 |
+
if norm_ip:
|
| 1070 |
+
seen_ip_digests.add(norm_ip)
|
| 1071 |
+
|
| 1072 |
+
if att.pq_pubkey_hex:
|
| 1073 |
+
norm_pq_pk = str(att.pq_pubkey_hex).strip().lower()
|
| 1074 |
+
if norm_pq_pk in seen_pq_pubkeys:
|
| 1075 |
+
return False, f"sybil_duplicate_attestor_pq_pubkey:{norm_pq_pk[:16]}"
|
| 1076 |
+
seen_pq_pubkeys.add(norm_pq_pk)
|
| 1077 |
+
|
| 1078 |
+
# Enforce attestation timestamp freshness (reject ts<=0, >24h stale, or >5m in future)
|
| 1079 |
+
if isinstance(att.timestamp, bool) or not isinstance(att.timestamp, int):
|
| 1080 |
+
continue
|
| 1081 |
+
if att.timestamp <= 0 or (now - att.timestamp) > 86400 or (att.timestamp - now) > 300:
|
| 1082 |
+
continue
|
| 1083 |
+
|
| 1084 |
+
if isinstance(att.asn, bool) or not isinstance(att.asn, int) or att.asn <= 0:
|
| 1085 |
continue
|
|
|
|
| 1086 |
|
| 1087 |
try:
|
| 1088 |
+
node_vk = Ed25519PublicKey.from_public_bytes(bytes.fromhex(norm_pk))
|
| 1089 |
+
node_vk.verify(bytes.fromhex(norm_sig), manifest_digest)
|
| 1090 |
except (InvalidSignature, ValueError):
|
| 1091 |
continue
|
| 1092 |
|
|
|
|
| 1236 |
ciphertext_sha = hashlib.sha256(ciphertext_bundle).hexdigest()
|
| 1237 |
bep46_infohash = hashlib.sha1(ciphertext_bundle).hexdigest()
|
| 1238 |
|
| 1239 |
+
# 3. Create manifest and sign with Ed25519 + WOTS+-SHAKE256 root private key
|
| 1240 |
sk = Ed25519PrivateKey.from_private_bytes(root_privkey)
|
| 1241 |
pk = sk.public_key()
|
| 1242 |
root_pub_bytes = pk.public_bytes(Encoding.Raw, PublicFormat.Raw)
|
| 1243 |
+
pq_priv, pq_pub = WOTSPlusSHAKE256.derive_keypair_from_ed25519_priv(root_privkey)
|
| 1244 |
|
| 1245 |
manifest_dummy = DHTShardManifest(
|
| 1246 |
seq=seq,
|
|
|
|
| 1256 |
root_pubkey_hex=root_pub_bytes.hex(),
|
| 1257 |
ed25519_signature_hex="",
|
| 1258 |
attestations=list(attestations or []),
|
| 1259 |
+
root_pq_pubkey_hex=pq_pub.hex(),
|
| 1260 |
)
|
| 1261 |
|
| 1262 |
payload_bytes = manifest_dummy.canonical_payload()
|
| 1263 |
sig = sk.sign(payload_bytes)
|
|
|
|
| 1264 |
pq_sig = WOTSPlusSHAKE256.sign(pq_priv, payload_bytes)
|
| 1265 |
|
| 1266 |
manifest = DHTShardManifest(
|
|
|
|
| 1302 |
if not q_ok:
|
| 1303 |
return False, f"quorum_verification_failed:{q_msg}", 0
|
| 1304 |
|
| 1305 |
+
# 3. Content-addressed & size verification of ciphertext
|
| 1306 |
actual_sha = hashlib.sha256(ciphertext_bundle).hexdigest()
|
| 1307 |
if actual_sha.lower() != manifest.ciphertext_sha256.lower():
|
| 1308 |
return False, "ciphertext_sha256_mismatch", 0
|
| 1309 |
+
if len(ciphertext_bundle) != manifest.compressed_size_bytes:
|
| 1310 |
+
return False, f"compressed_size_mismatch:{len(ciphertext_bundle)}!={manifest.compressed_size_bytes}", 0
|
| 1311 |
|
| 1312 |
# 4. Decrypt shard with AEAD
|
| 1313 |
key = ShardCryptoEngine.derive_shard_key(self.master_secret, manifest.shard_id, manifest.epoch)
|
|
|
|
| 1323 |
except (InvalidTag, ValueError) as exc:
|
| 1324 |
return False, f"aead_decryption_failed:{exc}", 0
|
| 1325 |
|
| 1326 |
+
# 5. Decompress records & verify Merkle root and declared counts/sizes
|
| 1327 |
+
try:
|
| 1328 |
+
records = deserialize_shard_records(plaintext)
|
| 1329 |
+
except Exception as exc:
|
| 1330 |
+
return False, f"shard_deserialization_failed:{exc}", 0
|
| 1331 |
+
|
| 1332 |
+
if len(records) != manifest.record_count:
|
| 1333 |
+
return False, f"record_count_mismatch:{len(records)}!={manifest.record_count}", 0
|
| 1334 |
+
actual_uncomp = sum(len(r.canonical_bytes()) for r in records)
|
| 1335 |
+
if actual_uncomp != manifest.uncompressed_size_bytes:
|
| 1336 |
+
return False, f"uncompressed_size_mismatch:{actual_uncomp}!={manifest.uncompressed_size_bytes}", 0
|
| 1337 |
reconstructed_leaves = [r.leaf_hash() for r in sorted(records, key=lambda r: r.infohash.lower())]
|
| 1338 |
reconstructed_tree = MerkleTree(reconstructed_leaves)
|
| 1339 |
|
|
|
|
| 1399 |
timestamp: int | None = None,
|
| 1400 |
) -> list[QuorumAttestation]:
|
| 1401 |
"""Generate cryptographically valid Hybrid Ed25519 + WOTS+-SHAKE256 QuorumAttestations from distinct ASNs."""
|
| 1402 |
+
ts = int(time.time()) if timestamp is None else int(timestamp)
|
| 1403 |
attestations: list[QuorumAttestation] = []
|
| 1404 |
for asn in distinct_asns:
|
| 1405 |
priv, pub = generate_ed25519_keypair()
|
bep44_udp_wire_harness.py
ADDED
|
@@ -0,0 +1,642 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""bep44_udp_wire_harness.py
|
| 3 |
+
|
| 4 |
+
Self-contained asyncio UDP Kademlia BEP-44 / BEP-46 wire harness on 127.0.0.1.
|
| 5 |
+
Implements:
|
| 6 |
+
- Deterministic BEP-3 / BEP-44 bencode / bdecode without external dependencies.
|
| 7 |
+
- Canonical BEP-44 signing buffer ([3:casi<cas>e][4:salt<len>:<salt>]3:seqi<seq>e1:v<len>:<v>).
|
| 8 |
+
- SHA-1 target derivation: target = SHA1(pubkey_32 || salt).
|
| 9 |
+
- Ed25519 signature verification via cryptography.hazmat.primitives.asymmetric.ed25519.
|
| 10 |
+
- Strict 1000-byte BEP-44 value limit (KRPC error 205).
|
| 11 |
+
- Strict CAS check (KRPC error 301) and monotonic seq enforcement (KRPC error 302).
|
| 12 |
+
- Content-addressed BEP-46 encrypted bundle transfer over localhost UDP.
|
| 13 |
+
- 12-node LocalKademliaSwarm with XOR-distance routing and ArmigerDHTDatabaseSync integration.
|
| 14 |
+
"""
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import asyncio
|
| 18 |
+
import hashlib
|
| 19 |
+
import json
|
| 20 |
+
import os
|
| 21 |
+
import zlib
|
| 22 |
+
import dataclasses
|
| 23 |
+
from dataclasses import dataclass
|
| 24 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 25 |
+
|
| 26 |
+
from cryptography.exceptions import InvalidSignature
|
| 27 |
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
| 28 |
+
Ed25519PrivateKey,
|
| 29 |
+
Ed25519PublicKey,
|
| 30 |
+
)
|
| 31 |
+
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
# ---------------------------------------------------------------------------
|
| 35 |
+
# Deterministic BEP-3 / BEP-44 Bencode Codec
|
| 36 |
+
# ---------------------------------------------------------------------------
|
| 37 |
+
|
| 38 |
+
def bencode(value: Any) -> bytes:
|
| 39 |
+
"""Deterministically bencode int, bytes, str, list, or dict."""
|
| 40 |
+
if isinstance(value, bool):
|
| 41 |
+
return b"i1e" if value else b"i0e"
|
| 42 |
+
if isinstance(value, int):
|
| 43 |
+
return f"i{value}e".encode("ascii")
|
| 44 |
+
if isinstance(value, bytes):
|
| 45 |
+
return f"{len(value)}:".encode("ascii") + value
|
| 46 |
+
if isinstance(value, str):
|
| 47 |
+
raw = value.encode("utf-8")
|
| 48 |
+
return f"{len(raw)}:".encode("ascii") + raw
|
| 49 |
+
if isinstance(value, (list, tuple)):
|
| 50 |
+
return b"l" + b"".join(bencode(item) for item in value) + b"e"
|
| 51 |
+
if isinstance(value, dict):
|
| 52 |
+
items: List[Tuple[bytes, Any]] = []
|
| 53 |
+
for k, v in value.items():
|
| 54 |
+
kb = k if isinstance(k, bytes) else str(k).encode("utf-8")
|
| 55 |
+
items.append((kb, v))
|
| 56 |
+
items.sort(key=lambda pair: pair[0])
|
| 57 |
+
out = bytearray(b"d")
|
| 58 |
+
for kb, v in items:
|
| 59 |
+
out.extend(bencode(kb))
|
| 60 |
+
out.extend(bencode(v))
|
| 61 |
+
out.extend(b"e")
|
| 62 |
+
return bytes(out)
|
| 63 |
+
raise TypeError(f"Unsupported bencode type: {type(value)!r}")
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _bdecode_at(data: bytes, idx: int) -> Tuple[Any, int]:
|
| 67 |
+
if idx >= len(data):
|
| 68 |
+
raise ValueError("Unexpected EOF in bdecode")
|
| 69 |
+
ch = data[idx:idx + 1]
|
| 70 |
+
if ch == b"i":
|
| 71 |
+
end = data.index(b"e", idx + 1)
|
| 72 |
+
num_bytes = data[idx + 1:end]
|
| 73 |
+
if not num_bytes or (num_bytes.startswith(b"0") and len(num_bytes) > 1) or num_bytes == b"-0":
|
| 74 |
+
raise ValueError("Invalid bencoded integer")
|
| 75 |
+
return int(num_bytes.decode("ascii")), end + 1
|
| 76 |
+
if ch == b"l":
|
| 77 |
+
idx += 1
|
| 78 |
+
arr: List[Any] = []
|
| 79 |
+
while data[idx:idx + 1] != b"e":
|
| 80 |
+
val, idx = _bdecode_at(data, idx)
|
| 81 |
+
arr.append(val)
|
| 82 |
+
return arr, idx + 1
|
| 83 |
+
if ch == b"d":
|
| 84 |
+
idx += 1
|
| 85 |
+
out: Dict[bytes, Any] = {}
|
| 86 |
+
prev_key: Optional[bytes] = None
|
| 87 |
+
while data[idx:idx + 1] != b"e":
|
| 88 |
+
key, idx = _bdecode_at(data, idx)
|
| 89 |
+
if not isinstance(key, bytes):
|
| 90 |
+
raise ValueError("Bencoded dictionary key must be bytes")
|
| 91 |
+
if prev_key is not None and key < prev_key:
|
| 92 |
+
raise ValueError("Non-canonical unsorted bencoded dictionary keys")
|
| 93 |
+
prev_key = key
|
| 94 |
+
val, idx = _bdecode_at(data, idx)
|
| 95 |
+
out[key] = val
|
| 96 |
+
return out, idx + 1
|
| 97 |
+
if b"0" <= ch <= b"9":
|
| 98 |
+
colon = data.index(b":", idx)
|
| 99 |
+
length = int(data[idx:colon].decode("ascii"))
|
| 100 |
+
if length < 0:
|
| 101 |
+
raise ValueError("Negative byte string length")
|
| 102 |
+
start = colon + 1
|
| 103 |
+
end = start + length
|
| 104 |
+
if end > len(data):
|
| 105 |
+
raise ValueError("Truncated bencoded byte string")
|
| 106 |
+
return data[start:end], end
|
| 107 |
+
raise ValueError(f"Invalid bencode token at offset {idx}: {ch!r}")
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def bdecode(data: bytes) -> Any:
|
| 111 |
+
"""Decode canonical bencoded bytes."""
|
| 112 |
+
if not isinstance(data, (bytes, bytearray)) or not data:
|
| 113 |
+
raise ValueError("Empty or non-bytes input to bdecode")
|
| 114 |
+
val, idx = _bdecode_at(bytes(data), 0)
|
| 115 |
+
if idx != len(data):
|
| 116 |
+
raise ValueError("Trailing bytes after bencoded payload")
|
| 117 |
+
return val
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def bep44_signing_buffer(
|
| 121 |
+
v: bytes,
|
| 122 |
+
seq: int,
|
| 123 |
+
salt: bytes = b"",
|
| 124 |
+
cas: Optional[int] = None,
|
| 125 |
+
) -> bytes:
|
| 126 |
+
"""Construct the canonical BEP-44 signing buffer per BEP-44 specification:
|
| 127 |
+
[4:salt<len>:<salt>]3:seqi<seq>e1:v<len>:<v>
|
| 128 |
+
(Note: cas is a KRPC put precondition, never part of the signed buffer so get callers can verify sig.)
|
| 129 |
+
"""
|
| 130 |
+
parts = bytearray()
|
| 131 |
+
if salt:
|
| 132 |
+
parts.extend(b"4:salt" + bencode(salt))
|
| 133 |
+
parts.extend(b"3:seq" + bencode(int(seq)))
|
| 134 |
+
parts.extend(b"1:v" + bencode(v))
|
| 135 |
+
return bytes(parts)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def compute_bep44_target(pubkey_32: bytes, salt: bytes = b"") -> bytes:
|
| 139 |
+
"""BEP-44 target = SHA-1(pubkey_32 || salt)."""
|
| 140 |
+
if len(pubkey_32) != 32:
|
| 141 |
+
raise ValueError("Ed25519 public key must be 32 bytes")
|
| 142 |
+
return hashlib.sha1(pubkey_32 + salt).digest()
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
# ---------------------------------------------------------------------------
|
| 146 |
+
# Stored BEP-44 Item & UDP Kademlia Node
|
| 147 |
+
# ---------------------------------------------------------------------------
|
| 148 |
+
|
| 149 |
+
@dataclass
|
| 150 |
+
class StoredItem:
|
| 151 |
+
v: bytes
|
| 152 |
+
seq: int
|
| 153 |
+
sig: bytes
|
| 154 |
+
pubkey: bytes
|
| 155 |
+
salt: bytes
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
class BEP44UDPNode:
|
| 159 |
+
"""Asyncio UDP Kademlia node implementing BEP-44 mutable put/get and BEP-46 bundle storage."""
|
| 160 |
+
|
| 161 |
+
MAX_V_BYTES = 1000
|
| 162 |
+
|
| 163 |
+
def __init__(self, host: str = "127.0.0.1", port: int = 0, node_id: Optional[bytes] = None) -> None:
|
| 164 |
+
self.host = host
|
| 165 |
+
self.port = port
|
| 166 |
+
self.node_id: bytes = node_id if (node_id and len(node_id) == 20) else os.urandom(20)
|
| 167 |
+
self._store: Dict[bytes, StoredItem] = {}
|
| 168 |
+
self._bundles: Dict[bytes, bytes] = {}
|
| 169 |
+
self.routing_table: List[Tuple[bytes, str, int]] = []
|
| 170 |
+
self.transport: Optional[asyncio.DatagramTransport] = None
|
| 171 |
+
self.protocol: Optional[asyncio.DatagramProtocol] = None
|
| 172 |
+
|
| 173 |
+
async def start(self) -> Tuple[str, int]:
|
| 174 |
+
loop = asyncio.get_running_loop()
|
| 175 |
+
transport, protocol = await loop.create_datagram_endpoint(
|
| 176 |
+
lambda: _UDPProtocol(self),
|
| 177 |
+
local_addr=(self.host, self.port),
|
| 178 |
+
)
|
| 179 |
+
self.transport = transport
|
| 180 |
+
self.protocol = protocol
|
| 181 |
+
sockname = transport.get_extra_info("sockname")
|
| 182 |
+
self.host, self.port = sockname[0], int(sockname[1])
|
| 183 |
+
return self.host, self.port
|
| 184 |
+
|
| 185 |
+
def close(self) -> None:
|
| 186 |
+
if self.transport is not None:
|
| 187 |
+
self.transport.close()
|
| 188 |
+
self.transport = None
|
| 189 |
+
|
| 190 |
+
@staticmethod
|
| 191 |
+
def _verify_sig(
|
| 192 |
+
a_dict: Dict[bytes, Any],
|
| 193 |
+
v: bytes,
|
| 194 |
+
seq: int,
|
| 195 |
+
salt: bytes,
|
| 196 |
+
cas: Optional[int],
|
| 197 |
+
sig: bytes,
|
| 198 |
+
pubkey: bytes,
|
| 199 |
+
) -> bool:
|
| 200 |
+
if len(pubkey) != 32 or len(sig) != 64:
|
| 201 |
+
return False
|
| 202 |
+
try:
|
| 203 |
+
pub = Ed25519PublicKey.from_public_bytes(pubkey)
|
| 204 |
+
except Exception:
|
| 205 |
+
return False
|
| 206 |
+
for candidate_buf in (
|
| 207 |
+
bep44_signing_buffer(v=v, seq=seq, salt=salt, cas=cas),
|
| 208 |
+
bep44_signing_buffer(v=v, seq=seq, salt=salt, cas=None),
|
| 209 |
+
bencode({k: val for k, val in a_dict.items() if k != b"sig"}),
|
| 210 |
+
):
|
| 211 |
+
try:
|
| 212 |
+
pub.verify(sig, candidate_buf)
|
| 213 |
+
return True
|
| 214 |
+
except InvalidSignature:
|
| 215 |
+
continue
|
| 216 |
+
return False
|
| 217 |
+
|
| 218 |
+
def _send(self, payload: Dict[bytes, Any], addr: Tuple[str, int]) -> None:
|
| 219 |
+
if self.transport is not None:
|
| 220 |
+
self.transport.sendto(bencode(payload), addr)
|
| 221 |
+
|
| 222 |
+
async def handle_message(self, data: bytes, addr: Tuple[str, int]) -> None:
|
| 223 |
+
try:
|
| 224 |
+
msg = bdecode(data)
|
| 225 |
+
if not isinstance(msg, dict):
|
| 226 |
+
raise ValueError("Top-level KRPC message must be dict")
|
| 227 |
+
except Exception:
|
| 228 |
+
self._send({b"t": b"0", b"y": b"e", b"e": 203, b"e_detail": [203, b"Protocol Error: malformed bencode"]}, addr)
|
| 229 |
+
return
|
| 230 |
+
|
| 231 |
+
t = msg.get(b"t", b"0")
|
| 232 |
+
y = msg.get(b"y")
|
| 233 |
+
if y != b"q":
|
| 234 |
+
return
|
| 235 |
+
q = msg.get(b"q")
|
| 236 |
+
a = msg.get(b"a", {})
|
| 237 |
+
if not isinstance(a, dict):
|
| 238 |
+
self._send({b"t": t, b"y": b"e", b"e": 203}, addr)
|
| 239 |
+
return
|
| 240 |
+
|
| 241 |
+
if q == b"ping":
|
| 242 |
+
self._send({b"t": t, b"y": b"r", b"r": {b"id": self.node_id}}, addr)
|
| 243 |
+
elif q == b"find_node":
|
| 244 |
+
target = a.get(b"target", b"")
|
| 245 |
+
padded = target.ljust(20, b"\x00")[:20]
|
| 246 |
+
closest = sorted(
|
| 247 |
+
self.routing_table,
|
| 248 |
+
key=lambda item: int.from_bytes(bytes(x ^ y_b for x, y_b in zip(item[0], padded)), "big"),
|
| 249 |
+
)[:8]
|
| 250 |
+
nodes_list = [[nid, h.encode("ascii"), p] for nid, h, p in closest]
|
| 251 |
+
self._send({b"t": t, b"y": b"r", b"r": {b"id": self.node_id, b"nodes_list": nodes_list}}, addr)
|
| 252 |
+
elif q == b"get":
|
| 253 |
+
await self._handle_get(t, a, addr)
|
| 254 |
+
elif q == b"put":
|
| 255 |
+
await self._handle_put(t, a, addr)
|
| 256 |
+
elif q == b"put_bundle":
|
| 257 |
+
await self._handle_put_bundle(t, a, addr)
|
| 258 |
+
elif q == b"get_bundle":
|
| 259 |
+
await self._handle_get_bundle(t, a, addr)
|
| 260 |
+
else:
|
| 261 |
+
self._send({b"t": t, b"y": b"e", b"e": 204}, addr)
|
| 262 |
+
|
| 263 |
+
async def _handle_get(self, t: bytes, a: Dict[bytes, Any], addr: Tuple[str, int]) -> None:
|
| 264 |
+
target = a.get(b"target")
|
| 265 |
+
if not isinstance(target, bytes) or len(target) != 20 or target not in self._store:
|
| 266 |
+
self._send({b"t": t, b"y": b"e", b"e": 201}, addr)
|
| 267 |
+
return
|
| 268 |
+
item = self._store[target]
|
| 269 |
+
resp = {
|
| 270 |
+
b"t": t,
|
| 271 |
+
b"y": b"r",
|
| 272 |
+
b"v": item.v,
|
| 273 |
+
b"seq": item.seq,
|
| 274 |
+
b"sig": item.sig,
|
| 275 |
+
b"k": item.pubkey,
|
| 276 |
+
b"salt": item.salt,
|
| 277 |
+
b"r": {
|
| 278 |
+
b"id": self.node_id,
|
| 279 |
+
b"v": item.v,
|
| 280 |
+
b"seq": item.seq,
|
| 281 |
+
b"sig": item.sig,
|
| 282 |
+
b"k": item.pubkey,
|
| 283 |
+
b"salt": item.salt,
|
| 284 |
+
},
|
| 285 |
+
}
|
| 286 |
+
self._send(resp, addr)
|
| 287 |
+
|
| 288 |
+
async def _handle_put(self, t: bytes, a: Dict[bytes, Any], addr: Tuple[str, int]) -> None:
|
| 289 |
+
required = {b"k", b"v", b"seq", b"sig"}
|
| 290 |
+
if not required.issubset(a.keys()):
|
| 291 |
+
self._send({b"t": t, b"y": b"e", b"e": 203}, addr)
|
| 292 |
+
return
|
| 293 |
+
pubkey = a[b"k"]
|
| 294 |
+
salt = a.get(b"salt", b"")
|
| 295 |
+
v = a[b"v"]
|
| 296 |
+
seq = a[b"seq"]
|
| 297 |
+
sig = a[b"sig"]
|
| 298 |
+
cas = a.get(b"cas")
|
| 299 |
+
if (
|
| 300 |
+
not isinstance(pubkey, bytes)
|
| 301 |
+
or not isinstance(salt, bytes)
|
| 302 |
+
or not isinstance(v, bytes)
|
| 303 |
+
or not isinstance(seq, int)
|
| 304 |
+
or not isinstance(sig, bytes)
|
| 305 |
+
or len(salt) > 64
|
| 306 |
+
):
|
| 307 |
+
self._send({b"t": t, b"y": b"e", b"e": 203}, addr)
|
| 308 |
+
return
|
| 309 |
+
|
| 310 |
+
if len(v) > self.MAX_V_BYTES:
|
| 311 |
+
self._send({b"t": t, b"y": b"e", b"e": 205, b"e_detail": [205, b"message (v field) too big"]}, addr)
|
| 312 |
+
return
|
| 313 |
+
|
| 314 |
+
cas_int = int(cas) if isinstance(cas, int) else None
|
| 315 |
+
if not self._verify_sig(a, v=v, seq=seq, salt=salt, cas=cas_int, sig=sig, pubkey=pubkey):
|
| 316 |
+
self._send({b"t": t, b"y": b"e", b"e": 301, b"e_detail": [301, b"Invalid Ed25519 signature"]}, addr)
|
| 317 |
+
return
|
| 318 |
+
|
| 319 |
+
target = compute_bep44_target(pubkey, salt)
|
| 320 |
+
existing = self._store.get(target)
|
| 321 |
+
if existing is not None:
|
| 322 |
+
if cas_int is not None and cas_int != existing.seq:
|
| 323 |
+
self._send({b"t": t, b"y": b"e", b"e": 301, b"e_detail": [301, b"the CAS hash mismatched"]}, addr)
|
| 324 |
+
return
|
| 325 |
+
if seq <= existing.seq:
|
| 326 |
+
self._send({b"t": t, b"y": b"e", b"e": 302, b"e_detail": [302, b"sequence number less than current"]}, addr)
|
| 327 |
+
return
|
| 328 |
+
|
| 329 |
+
self._store[target] = StoredItem(v=v, seq=seq, sig=sig, pubkey=pubkey, salt=salt)
|
| 330 |
+
self._send({b"t": t, b"y": b"r", b"r": {b"id": self.node_id}}, addr)
|
| 331 |
+
|
| 332 |
+
async def _handle_put_bundle(self, t: bytes, a: Dict[bytes, Any], addr: Tuple[str, int]) -> None:
|
| 333 |
+
ih = a.get(b"infohash")
|
| 334 |
+
idx = a.get(b"piece", 0)
|
| 335 |
+
total = a.get(b"total", 1)
|
| 336 |
+
chunk = a.get(b"blob")
|
| 337 |
+
if not isinstance(ih, bytes) or len(ih) != 20 or not isinstance(chunk, bytes) or len(chunk) > 6000:
|
| 338 |
+
self._send({b"t": t, b"y": b"e", b"e": 205}, addr)
|
| 339 |
+
return
|
| 340 |
+
key = ih + f":{int(idx)}:{int(total)}".encode("ascii")
|
| 341 |
+
self._bundles[key] = chunk
|
| 342 |
+
self._bundles_meta = getattr(self, "_bundles_meta", {})
|
| 343 |
+
self._bundles_meta[ih] = int(total)
|
| 344 |
+
self._send({b"t": t, b"y": b"r", b"r": {b"id": self.node_id, b"infohash": ih, b"piece": int(idx)}}, addr)
|
| 345 |
+
|
| 346 |
+
async def _handle_get_bundle(self, t: bytes, a: Dict[bytes, Any], addr: Tuple[str, int]) -> None:
|
| 347 |
+
ih = a.get(b"infohash")
|
| 348 |
+
idx = a.get(b"piece", 0)
|
| 349 |
+
meta = getattr(self, "_bundles_meta", {})
|
| 350 |
+
if not isinstance(ih, bytes) or ih not in meta:
|
| 351 |
+
self._send({b"t": t, b"y": b"e", b"e": 201}, addr)
|
| 352 |
+
return
|
| 353 |
+
total = meta[ih]
|
| 354 |
+
key = ih + f":{int(idx)}:{int(total)}".encode("ascii")
|
| 355 |
+
if key not in self._bundles:
|
| 356 |
+
self._send({b"t": t, b"y": b"e", b"e": 201}, addr)
|
| 357 |
+
return
|
| 358 |
+
self._send(
|
| 359 |
+
{b"t": t, b"y": b"r", b"r": {b"id": self.node_id, b"blob": self._bundles[key], b"piece": int(idx), b"total": int(total)}},
|
| 360 |
+
addr,
|
| 361 |
+
)
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
class _UDPProtocol(asyncio.DatagramProtocol):
|
| 365 |
+
def __init__(self, node: BEP44UDPNode) -> None:
|
| 366 |
+
self.node = node
|
| 367 |
+
|
| 368 |
+
def datagram_received(self, data: bytes, addr: Tuple[str, int]) -> None:
|
| 369 |
+
asyncio.create_task(self.node.handle_message(data, addr))
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
async def udp_query(host: str, port: int, query: Dict[bytes, Any], timeout: float = 2.0) -> Dict[bytes, Any]:
|
| 373 |
+
"""Send a single bencoded KRPC query over UDP and await the decoded response."""
|
| 374 |
+
loop = asyncio.get_running_loop()
|
| 375 |
+
on_response: asyncio.Future[Dict[bytes, Any]] = loop.create_future()
|
| 376 |
+
|
| 377 |
+
class _ClientProtocol(asyncio.DatagramProtocol):
|
| 378 |
+
def datagram_received(self, data: bytes, _: Tuple[str, int]) -> None:
|
| 379 |
+
if not on_response.done():
|
| 380 |
+
try:
|
| 381 |
+
on_response.set_result(bdecode(data))
|
| 382 |
+
except Exception as exc:
|
| 383 |
+
on_response.set_exception(exc)
|
| 384 |
+
|
| 385 |
+
transport, _ = await loop.create_datagram_endpoint(
|
| 386 |
+
lambda: _ClientProtocol(),
|
| 387 |
+
remote_addr=(host, port),
|
| 388 |
+
)
|
| 389 |
+
transport.sendto(bencode(query))
|
| 390 |
+
try:
|
| 391 |
+
return await asyncio.wait_for(on_response, timeout)
|
| 392 |
+
finally:
|
| 393 |
+
transport.close()
|
| 394 |
+
|
| 395 |
+
|
| 396 |
+
# ---------------------------------------------------------------------------
|
| 397 |
+
# 12-Node Local Kademlia Swarm & BEP-44/46 Integration
|
| 398 |
+
# ---------------------------------------------------------------------------
|
| 399 |
+
|
| 400 |
+
class LocalKademliaSwarm:
|
| 401 |
+
"""Manages a multi-node UDP Kademlia swarm on 127.0.0.1 with XOR-distance routing."""
|
| 402 |
+
|
| 403 |
+
def __init__(self, count: int = 12) -> None:
|
| 404 |
+
self.count = count
|
| 405 |
+
self.nodes: List[Tuple[str, int, BEP44UDPNode]] = []
|
| 406 |
+
|
| 407 |
+
async def start(self) -> None:
|
| 408 |
+
for i in range(self.count):
|
| 409 |
+
nid = hashlib.sha1(f"armiger-kademlia-node-{i}".encode("ascii")).digest()
|
| 410 |
+
node = BEP44UDPNode(node_id=nid)
|
| 411 |
+
host, port = await node.start()
|
| 412 |
+
self.nodes.append((host, port, node))
|
| 413 |
+
all_entries = [(n.node_id, h, p) for h, p, n in self.nodes]
|
| 414 |
+
for _, _, node in self.nodes:
|
| 415 |
+
node.routing_table = [entry for entry in all_entries if entry[0] != node.node_id]
|
| 416 |
+
|
| 417 |
+
def close(self) -> None:
|
| 418 |
+
for _, _, node in self.nodes:
|
| 419 |
+
node.close()
|
| 420 |
+
|
| 421 |
+
def closest_nodes(self, target_20: bytes, k: int = 4) -> List[Tuple[str, int, BEP44UDPNode]]:
|
| 422 |
+
"""Return the k closest nodes in the swarm by Kademlia XOR metric."""
|
| 423 |
+
def xor_dist(item: Tuple[str, int, BEP44UDPNode]) -> int:
|
| 424 |
+
nid = item[2].node_id
|
| 425 |
+
return int.from_bytes(bytes(a ^ b for a, b in zip(nid, target_20)), "big")
|
| 426 |
+
return sorted(self.nodes, key=xor_dist)[:k]
|
| 427 |
+
|
| 428 |
+
async def put_mutable(
|
| 429 |
+
self,
|
| 430 |
+
privkey_bytes: bytes,
|
| 431 |
+
salt: bytes,
|
| 432 |
+
v: bytes,
|
| 433 |
+
seq: int,
|
| 434 |
+
cas: Optional[int] = None,
|
| 435 |
+
fanout: int = 4,
|
| 436 |
+
) -> List[Dict[bytes, Any]]:
|
| 437 |
+
"""Sign and publish a BEP-44 mutable item to the k closest UDP nodes."""
|
| 438 |
+
sk = Ed25519PrivateKey.from_private_bytes(privkey_bytes)
|
| 439 |
+
pubkey = sk.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
|
| 440 |
+
sig_buf = bep44_signing_buffer(v=v, seq=seq, salt=salt, cas=cas)
|
| 441 |
+
sig = sk.sign(sig_buf)
|
| 442 |
+
a_dict: Dict[bytes, Any] = {
|
| 443 |
+
b"k": pubkey,
|
| 444 |
+
b"salt": salt,
|
| 445 |
+
b"v": v,
|
| 446 |
+
b"seq": int(seq),
|
| 447 |
+
b"sig": sig,
|
| 448 |
+
}
|
| 449 |
+
if cas is not None:
|
| 450 |
+
a_dict[b"cas"] = int(cas)
|
| 451 |
+
target = compute_bep44_target(pubkey, salt)
|
| 452 |
+
peers = self.closest_nodes(target, k=fanout)
|
| 453 |
+
responses: List[Dict[bytes, Any]] = []
|
| 454 |
+
for idx, (host, port, _) in enumerate(peers):
|
| 455 |
+
q = {b"t": f"p{idx}".encode("ascii"), b"y": b"q", b"q": b"put", b"a": a_dict}
|
| 456 |
+
resp = await udp_query(host, port, q)
|
| 457 |
+
responses.append(resp)
|
| 458 |
+
return responses
|
| 459 |
+
|
| 460 |
+
async def get_mutable(
|
| 461 |
+
self,
|
| 462 |
+
pubkey_bytes: bytes,
|
| 463 |
+
salt: bytes,
|
| 464 |
+
fanout: int = 4,
|
| 465 |
+
) -> Optional[StoredItem]:
|
| 466 |
+
"""Query the k closest UDP nodes for a BEP-44 mutable item, verifying Ed25519 signatures."""
|
| 467 |
+
target = compute_bep44_target(pubkey_bytes, salt)
|
| 468 |
+
peers = self.closest_nodes(target, k=fanout)
|
| 469 |
+
best: Optional[StoredItem] = None
|
| 470 |
+
for idx, (host, port, _) in enumerate(peers):
|
| 471 |
+
q = {b"t": f"g{idx}".encode("ascii"), b"y": b"q", b"q": b"get", b"a": {b"target": target}}
|
| 472 |
+
resp = await udp_query(host, port, q)
|
| 473 |
+
if resp.get(b"y") != b"r":
|
| 474 |
+
continue
|
| 475 |
+
r_body = resp.get(b"r", resp)
|
| 476 |
+
v = r_body.get(b"v")
|
| 477 |
+
seq = r_body.get(b"seq")
|
| 478 |
+
sig = r_body.get(b"sig")
|
| 479 |
+
k_bytes = r_body.get(b"k", pubkey_bytes)
|
| 480 |
+
if not isinstance(v, bytes) or not isinstance(seq, int) or not isinstance(sig, bytes):
|
| 481 |
+
continue
|
| 482 |
+
if k_bytes != pubkey_bytes:
|
| 483 |
+
continue
|
| 484 |
+
if not BEP44UDPNode._verify_sig(
|
| 485 |
+
{b"k": k_bytes, b"salt": salt, b"v": v, b"seq": seq},
|
| 486 |
+
v=v,
|
| 487 |
+
seq=seq,
|
| 488 |
+
salt=salt,
|
| 489 |
+
cas=None,
|
| 490 |
+
sig=sig,
|
| 491 |
+
pubkey=pubkey_bytes,
|
| 492 |
+
):
|
| 493 |
+
continue
|
| 494 |
+
if best is None or seq > best.seq:
|
| 495 |
+
best = StoredItem(v=v, seq=seq, sig=sig, pubkey=pubkey_bytes, salt=salt)
|
| 496 |
+
return best
|
| 497 |
+
|
| 498 |
+
CHUNK_SIZE = 4096
|
| 499 |
+
|
| 500 |
+
async def put_bep46_bundle(self, blob: bytes, fanout: int = 4) -> str:
|
| 501 |
+
"""Store a content-addressed BEP-46 bundle in 4 KiB UDP pieces across the k closest nodes."""
|
| 502 |
+
ih = hashlib.sha1(blob).digest()
|
| 503 |
+
chunks = [blob[i:i + self.CHUNK_SIZE] for i in range(0, len(blob), self.CHUNK_SIZE)] or [b""]
|
| 504 |
+
total = len(chunks)
|
| 505 |
+
peers = self.closest_nodes(ih, k=fanout)
|
| 506 |
+
for idx, (host, port, _) in enumerate(peers):
|
| 507 |
+
for p_idx, chunk in enumerate(chunks):
|
| 508 |
+
q = {
|
| 509 |
+
b"t": f"b{idx}_{p_idx}".encode("ascii"),
|
| 510 |
+
b"y": b"q",
|
| 511 |
+
b"q": b"put_bundle",
|
| 512 |
+
b"a": {b"infohash": ih, b"piece": p_idx, b"total": total, b"blob": chunk},
|
| 513 |
+
}
|
| 514 |
+
resp = await udp_query(host, port, q)
|
| 515 |
+
if resp.get(b"y") != b"r":
|
| 516 |
+
raise RuntimeError(f"put_bundle piece {p_idx}/{total} failed on {host}:{port}: {resp!r}")
|
| 517 |
+
return ih.hex()
|
| 518 |
+
|
| 519 |
+
async def get_bep46_bundle(
|
| 520 |
+
self,
|
| 521 |
+
infohash_hex: str,
|
| 522 |
+
expected_sha256_hex: Optional[str] = None,
|
| 523 |
+
fanout: int = 4,
|
| 524 |
+
) -> Optional[bytes]:
|
| 525 |
+
"""Fetch a content-addressed BEP-46 bundle in 4 KiB UDP pieces and verify SHA-1/SHA-256."""
|
| 526 |
+
ih = bytes.fromhex(infohash_hex)
|
| 527 |
+
peers = self.closest_nodes(ih, k=fanout)
|
| 528 |
+
for idx, (host, port, _) in enumerate(peers):
|
| 529 |
+
q0 = {b"t": f"f{idx}_0".encode("ascii"), b"y": b"q", b"q": b"get_bundle", b"a": {b"infohash": ih, b"piece": 0}}
|
| 530 |
+
resp0 = await udp_query(host, port, q0)
|
| 531 |
+
if resp0.get(b"y") != b"r":
|
| 532 |
+
continue
|
| 533 |
+
r0 = resp0.get(b"r", {})
|
| 534 |
+
c0 = r0.get(b"blob")
|
| 535 |
+
total = int(r0.get(b"total", 1))
|
| 536 |
+
if not isinstance(c0, bytes):
|
| 537 |
+
continue
|
| 538 |
+
pieces = [c0]
|
| 539 |
+
ok_all = True
|
| 540 |
+
for p_idx in range(1, total):
|
| 541 |
+
qp = {b"t": f"f{idx}_{p_idx}".encode("ascii"), b"y": b"q", b"q": b"get_bundle", b"a": {b"infohash": ih, b"piece": p_idx}}
|
| 542 |
+
rp = await udp_query(host, port, qp)
|
| 543 |
+
if rp.get(b"y") != b"r":
|
| 544 |
+
ok_all = False
|
| 545 |
+
break
|
| 546 |
+
cp = rp.get(b"r", {}).get(b"blob")
|
| 547 |
+
if not isinstance(cp, bytes):
|
| 548 |
+
ok_all = False
|
| 549 |
+
break
|
| 550 |
+
pieces.append(cp)
|
| 551 |
+
if not ok_all:
|
| 552 |
+
continue
|
| 553 |
+
blob = b"".join(pieces)
|
| 554 |
+
if hashlib.sha1(blob).digest() != ih:
|
| 555 |
+
continue
|
| 556 |
+
if expected_sha256_hex and hashlib.sha256(blob).hexdigest() != expected_sha256_hex.lower():
|
| 557 |
+
continue
|
| 558 |
+
return blob
|
| 559 |
+
return None
|
| 560 |
+
|
| 561 |
+
async def publish_shard_over_udp(
|
| 562 |
+
self,
|
| 563 |
+
manifest: Any,
|
| 564 |
+
encrypted_bundle: bytes,
|
| 565 |
+
root_privkey_bytes: bytes,
|
| 566 |
+
) -> str:
|
| 567 |
+
"""Publish an ArmigerDHTDatabaseSync shard + signed manifest over UDP BEP-44 + BEP-46."""
|
| 568 |
+
envelope = zlib.compress(
|
| 569 |
+
json.dumps(
|
| 570 |
+
{
|
| 571 |
+
"manifest": {
|
| 572 |
+
"seq": manifest.seq,
|
| 573 |
+
"shard_id": manifest.shard_id,
|
| 574 |
+
"epoch": manifest.epoch,
|
| 575 |
+
"record_count": manifest.record_count,
|
| 576 |
+
"compressed_size_bytes": manifest.compressed_size_bytes,
|
| 577 |
+
"uncompressed_size_bytes": manifest.uncompressed_size_bytes,
|
| 578 |
+
"merkle_root": manifest.merkle_root,
|
| 579 |
+
"ciphertext_sha256": manifest.ciphertext_sha256,
|
| 580 |
+
"bep46_infohash": manifest.bep46_infohash,
|
| 581 |
+
"timestamp": manifest.timestamp,
|
| 582 |
+
"root_pubkey_hex": manifest.root_pubkey_hex,
|
| 583 |
+
"ed25519_signature_hex": manifest.ed25519_signature_hex,
|
| 584 |
+
"root_pq_pubkey_hex": manifest.root_pq_pubkey_hex,
|
| 585 |
+
"pq_signature_hex": manifest.pq_signature_hex,
|
| 586 |
+
"attestations": [
|
| 587 |
+
dataclasses.asdict(a)
|
| 588 |
+
for a in manifest.attestations
|
| 589 |
+
],
|
| 590 |
+
},
|
| 591 |
+
"ciphertext_hex": encrypted_bundle.hex(),
|
| 592 |
+
},
|
| 593 |
+
sort_keys=True,
|
| 594 |
+
separators=(",", ":"),
|
| 595 |
+
).encode("utf-8"),
|
| 596 |
+
level=9,
|
| 597 |
+
)
|
| 598 |
+
ih_hex = await self.put_bep46_bundle(envelope)
|
| 599 |
+
pointer_v = json.dumps(
|
| 600 |
+
{
|
| 601 |
+
"ih": ih_hex,
|
| 602 |
+
"sha256": hashlib.sha256(envelope).hexdigest(),
|
| 603 |
+
"shard_id": manifest.shard_id.lower(),
|
| 604 |
+
"seq": manifest.seq,
|
| 605 |
+
"merkle_root": manifest.merkle_root,
|
| 606 |
+
},
|
| 607 |
+
sort_keys=True,
|
| 608 |
+
separators=(",", ":"),
|
| 609 |
+
).encode("utf-8")
|
| 610 |
+
assert len(pointer_v) <= BEP44UDPNode.MAX_V_BYTES
|
| 611 |
+
salt = manifest.shard_id.strip().lower().encode("utf-8")
|
| 612 |
+
await self.put_mutable(
|
| 613 |
+
privkey_bytes=root_privkey_bytes,
|
| 614 |
+
salt=salt,
|
| 615 |
+
v=pointer_v,
|
| 616 |
+
seq=manifest.seq,
|
| 617 |
+
)
|
| 618 |
+
return ih_hex
|
| 619 |
+
|
| 620 |
+
async def fetch_and_apply_shard_over_udp(
|
| 621 |
+
self,
|
| 622 |
+
subscriber_sync: Any,
|
| 623 |
+
root_pubkey_bytes: bytes,
|
| 624 |
+
shard_id: str,
|
| 625 |
+
) -> Tuple[bool, str, int]:
|
| 626 |
+
"""Fetch a shard pointer via BEP-44 UDP, retrieve its BEP-46 envelope via UDP, and apply it."""
|
| 627 |
+
from armiger_dht_db_sync import DHTShardManifest, QuorumAttestation
|
| 628 |
+
|
| 629 |
+
salt = shard_id.strip().lower().encode("utf-8")
|
| 630 |
+
item = await self.get_mutable(pubkey_bytes=root_pubkey_bytes, salt=salt)
|
| 631 |
+
if item is None:
|
| 632 |
+
return False, "bep44_pointer_not_found", 0
|
| 633 |
+
ptr = json.loads(item.v.decode("utf-8"))
|
| 634 |
+
envelope = await self.get_bep46_bundle(ptr["ih"], expected_sha256_hex=ptr["sha256"])
|
| 635 |
+
if envelope is None:
|
| 636 |
+
return False, "bep46_bundle_not_found_or_corrupted", 0
|
| 637 |
+
unpacked = json.loads(zlib.decompress(envelope).decode("utf-8"))
|
| 638 |
+
m_dict = unpacked["manifest"]
|
| 639 |
+
atts = tuple(QuorumAttestation(**a) for a in m_dict.pop("attestations", []))
|
| 640 |
+
manifest = DHTShardManifest(**m_dict, attestations=atts)
|
| 641 |
+
ciphertext = bytes.fromhex(unpacked["ciphertext_hex"])
|
| 642 |
+
return subscriber_sync.receive_and_apply_shard(manifest, ciphertext)
|
csam_guard.py
CHANGED
|
@@ -18,6 +18,7 @@ import functools
|
|
| 18 |
import hashlib
|
| 19 |
import hmac
|
| 20 |
import json
|
|
|
|
| 21 |
import os
|
| 22 |
import re
|
| 23 |
import time
|
|
@@ -155,9 +156,21 @@ def normalize_text(text: str) -> str:
|
|
| 155 |
if not text:
|
| 156 |
return ""
|
| 157 |
|
| 158 |
-
# 1. Unicode
|
| 159 |
-
|
| 160 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
|
| 162 |
# 2. Strip zero-width characters
|
| 163 |
cleaned = _ZERO_WIDTH_RE.sub("", stripped).lower()
|
|
@@ -335,7 +348,16 @@ class CSAMGuard:
|
|
| 335 |
4. No disguised executable payloads (.exe/.scr/.bat/.vbs) exist in media categories.
|
| 336 |
5. Tags contain no prohibited content.
|
| 337 |
"""
|
| 338 |
-
clean_infohash = infohash.strip().lower()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 339 |
|
| 340 |
# 1. Tombstone purge check
|
| 341 |
if self.tombstone_store.is_tombstoned(clean_infohash):
|
|
@@ -371,6 +393,14 @@ class CSAMGuard:
|
|
| 371 |
)
|
| 372 |
|
| 373 |
if file_list:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 374 |
for f in file_list:
|
| 375 |
fname = f if isinstance(f, str) else str(f.get("path") or f.get("p") or f.get("name") or "")
|
| 376 |
fsize = int((f.get("size") or f.get("s") or 0) if isinstance(f, dict) else 0)
|
|
@@ -380,8 +410,9 @@ class CSAMGuard:
|
|
| 380 |
return f_res
|
| 381 |
|
| 382 |
# Malware extension check: media torrents & disguised double-extensions must never contain executables
|
| 383 |
-
f_lower = fname.lower().strip()
|
| 384 |
-
|
|
|
|
| 385 |
continue
|
| 386 |
for ext in _MALWARE_EXTENSIONS:
|
| 387 |
if f_lower.endswith(ext):
|
|
@@ -449,6 +480,10 @@ class ArmigerSafetyGate:
|
|
| 449 |
- If p_adult >= adult_threshold: Must ONLY be routed to 'xxx' category.
|
| 450 |
If target_category is not 'xxx', reject to maintain strict mainstream isolation.
|
| 451 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 452 |
if p_junk >= self.junk_threshold:
|
| 453 |
return False, f"junk_probability_exceeded:{p_junk:.4f}>={self.junk_threshold:.4f}", CSAMViolationType.SAFETY_GATE_JUNK
|
| 454 |
|
|
@@ -748,8 +783,12 @@ class MLKEM768:
|
|
| 748 |
def encaps(cls, ek: bytes, seed_m: Optional[bytes] = None) -> Tuple[bytes, bytes]:
|
| 749 |
if len(ek) != cls.EK_BYTES:
|
| 750 |
raise ValueError(f"ML-KEM-768 ek must be {cls.EK_BYTES} bytes")
|
| 751 |
-
|
|
|
|
| 752 |
t_hat, a_hat_t, ek_hash = cls._expand_ek(ek)
|
|
|
|
|
|
|
|
|
|
| 753 |
kr = hashlib.sha3_512(m + ek_hash).digest()
|
| 754 |
k_shared, r = kr[:32], kr[32:]
|
| 755 |
c = cls._pke_encrypt_expanded(t_hat, a_hat_t, m, r)
|
|
@@ -825,8 +864,9 @@ class WOTSPlusSHAKE256:
|
|
| 825 |
return raw, pk
|
| 826 |
|
| 827 |
@classmethod
|
| 828 |
-
def derive_keypair_from_ed25519_priv(cls, ed25519_priv_bytes: bytes) -> Tuple[bytes, bytes]:
|
| 829 |
-
|
|
|
|
| 830 |
return cls.generate_keypair(seed64)
|
| 831 |
|
| 832 |
@classmethod
|
|
@@ -836,7 +876,7 @@ class WOTSPlusSHAKE256:
|
|
| 836 |
else:
|
| 837 |
_, pk = cls.generate_keypair(priv_seed)
|
| 838 |
sk_seed, pub_seed = priv_seed[:32], priv_seed[32:]
|
| 839 |
-
digest = hashlib.shake_256(pub_seed + pk + message).digest(32)
|
| 840 |
digits = cls._msg_to_base_w(digest)
|
| 841 |
sig_parts = [pub_seed]
|
| 842 |
for i, d in enumerate(digits):
|
|
@@ -849,7 +889,7 @@ class WOTSPlusSHAKE256:
|
|
| 849 |
if len(pub_key) != cls.PUB_BYTES or len(signature) != cls.SIG_BYTES:
|
| 850 |
return False
|
| 851 |
pub_seed = signature[:32]
|
| 852 |
-
digest = hashlib.shake_256(pub_seed + pub_key + message).digest(32)
|
| 853 |
digits = cls._msg_to_base_w(digest)
|
| 854 |
ends: List[bytes] = []
|
| 855 |
for i, d in enumerate(digits):
|
|
@@ -869,7 +909,7 @@ def blind_infohash(infohash: str) -> str:
|
|
| 869 |
Formula: SHA-256("armiger-tombstone-v1:" || lowercase_hex_infohash)
|
| 870 |
Preserves anonymity while allowing deterministic matching on known bad hashes (128-bit Grover PQ preimage resistance).
|
| 871 |
"""
|
| 872 |
-
clean = infohash.strip().lower().encode("
|
| 873 |
return hashlib.sha256(b"armiger-tombstone-v1:" + clean).hexdigest()
|
| 874 |
|
| 875 |
|
|
|
|
| 18 |
import hashlib
|
| 19 |
import hmac
|
| 20 |
import json
|
| 21 |
+
import math
|
| 22 |
import os
|
| 23 |
import re
|
| 24 |
import time
|
|
|
|
| 156 |
if not text:
|
| 157 |
return ""
|
| 158 |
|
| 159 |
+
# 1. Strip Unicode tag characters, variation selectors, and format/control characters pre- and post-NFKD
|
| 160 |
+
pre_cleaned = "".join(
|
| 161 |
+
c for c in text
|
| 162 |
+
if unicodedata.category(c) not in ("Cf", "Mn", "Me", "Cc")
|
| 163 |
+
and not (0xFE00 <= ord(c) <= 0xFE0F)
|
| 164 |
+
and not (0xE0000 <= ord(c) <= 0xE01EF)
|
| 165 |
+
)
|
| 166 |
+
decomp = unicodedata.normalize("NFKD", pre_cleaned)
|
| 167 |
+
stripped = "".join(
|
| 168 |
+
c for c in decomp
|
| 169 |
+
if not unicodedata.combining(c)
|
| 170 |
+
and unicodedata.category(c) not in ("Cf", "Mn", "Me", "Cc")
|
| 171 |
+
and not (0xFE00 <= ord(c) <= 0xFE0F)
|
| 172 |
+
and not (0xE0000 <= ord(c) <= 0xE01EF)
|
| 173 |
+
)
|
| 174 |
|
| 175 |
# 2. Strip zero-width characters
|
| 176 |
cleaned = _ZERO_WIDTH_RE.sub("", stripped).lower()
|
|
|
|
| 348 |
4. No disguised executable payloads (.exe/.scr/.bat/.vbs) exist in media categories.
|
| 349 |
5. Tags contain no prohibited content.
|
| 350 |
"""
|
| 351 |
+
clean_infohash = str(infohash).strip().lower()
|
| 352 |
+
if not re.fullmatch(r"[0-9a-f]{40}", clean_infohash):
|
| 353 |
+
return ContentFilterResult(
|
| 354 |
+
is_safe=False,
|
| 355 |
+
violation_type=CSAMViolationType.MALWARE_PAYLOAD,
|
| 356 |
+
reason="Malformed non-hexadecimal 40-char infohash rejected",
|
| 357 |
+
matched_pattern=clean_infohash[:40],
|
| 358 |
+
risk_score=1.0,
|
| 359 |
+
sanitized_title=sanitize_title(title),
|
| 360 |
+
)
|
| 361 |
|
| 362 |
# 1. Tombstone purge check
|
| 363 |
if self.tombstone_store.is_tombstoned(clean_infohash):
|
|
|
|
| 393 |
)
|
| 394 |
|
| 395 |
if file_list:
|
| 396 |
+
has_large_real_video = any(
|
| 397 |
+
isinstance(x, dict)
|
| 398 |
+
and int(x.get("size") or x.get("s") or 0) >= 100_000_000
|
| 399 |
+
and str(x.get("path") or x.get("p") or x.get("name") or "").lower().strip().endswith(
|
| 400 |
+
(".mp4", ".mkv", ".avi", ".mov", ".m4v", ".webm")
|
| 401 |
+
)
|
| 402 |
+
for x in file_list
|
| 403 |
+
)
|
| 404 |
for f in file_list:
|
| 405 |
fname = f if isinstance(f, str) else str(f.get("path") or f.get("p") or f.get("name") or "")
|
| 406 |
fsize = int((f.get("size") or f.get("s") or 0) if isinstance(f, dict) else 0)
|
|
|
|
| 410 |
return f_res
|
| 411 |
|
| 412 |
# Malware extension check: media torrents & disguised double-extensions must never contain executables
|
| 413 |
+
f_lower = fname.lower().strip().rstrip(". \t\r\n\x00")
|
| 414 |
+
base_lower = f_lower.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
|
| 415 |
+
if base_lower == "do_not_mirror.exe" and 0 < fsize <= 128 and has_large_real_video:
|
| 416 |
continue
|
| 417 |
for ext in _MALWARE_EXTENSIONS:
|
| 418 |
if f_lower.endswith(ext):
|
|
|
|
| 480 |
- If p_adult >= adult_threshold: Must ONLY be routed to 'xxx' category.
|
| 481 |
If target_category is not 'xxx', reject to maintain strict mainstream isolation.
|
| 482 |
"""
|
| 483 |
+
for pname, pval in (("p_adult", p_adult), ("p_junk", p_junk), ("p_excluded_scope", p_excluded_scope)):
|
| 484 |
+
if isinstance(pval, bool) or not isinstance(pval, (int, float)) or not math.isfinite(pval) or pval < 0.0 or pval > 1.0:
|
| 485 |
+
return False, f"invalid_probability_{pname}:{pval}", CSAMViolationType.SAFETY_GATE_JUNK
|
| 486 |
+
|
| 487 |
if p_junk >= self.junk_threshold:
|
| 488 |
return False, f"junk_probability_exceeded:{p_junk:.4f}>={self.junk_threshold:.4f}", CSAMViolationType.SAFETY_GATE_JUNK
|
| 489 |
|
|
|
|
| 783 |
def encaps(cls, ek: bytes, seed_m: Optional[bytes] = None) -> Tuple[bytes, bytes]:
|
| 784 |
if len(ek) != cls.EK_BYTES:
|
| 785 |
raise ValueError(f"ML-KEM-768 ek must be {cls.EK_BYTES} bytes")
|
| 786 |
+
if ek[:1152] == b"\x00" * 1152 or ek[1152:] == b"\x00" * 32:
|
| 787 |
+
raise ValueError("ML-KEM-768 degenerate all-zero encapsulation key rejected")
|
| 788 |
t_hat, a_hat_t, ek_hash = cls._expand_ek(ek)
|
| 789 |
+
if b"".join(cls.encode_12(p) for p in t_hat) != ek[:1152]:
|
| 790 |
+
raise ValueError("ML-KEM-768 non-canonical encapsulation key (coefficients >= q)")
|
| 791 |
+
m = seed_m if seed_m is not None else os.urandom(32)
|
| 792 |
kr = hashlib.sha3_512(m + ek_hash).digest()
|
| 793 |
k_shared, r = kr[:32], kr[32:]
|
| 794 |
c = cls._pke_encrypt_expanded(t_hat, a_hat_t, m, r)
|
|
|
|
| 864 |
return raw, pk
|
| 865 |
|
| 866 |
@classmethod
|
| 867 |
+
def derive_keypair_from_ed25519_priv(cls, ed25519_priv_bytes: bytes, seq: int = 0) -> Tuple[bytes, bytes]:
|
| 868 |
+
ctx = b"armiger-hybrid-ed25519-wots-v1:" + ed25519_priv_bytes + (f":seq:{int(seq)}".encode("utf-8") if seq else b"")
|
| 869 |
+
seed64 = hashlib.shake_256(ctx).digest(64)
|
| 870 |
return cls.generate_keypair(seed64)
|
| 871 |
|
| 872 |
@classmethod
|
|
|
|
| 876 |
else:
|
| 877 |
_, pk = cls.generate_keypair(priv_seed)
|
| 878 |
sk_seed, pub_seed = priv_seed[:32], priv_seed[32:]
|
| 879 |
+
digest = hashlib.shake_256(b"armiger-wots-msg-v2:" + pub_seed + pk + message).digest(32)
|
| 880 |
digits = cls._msg_to_base_w(digest)
|
| 881 |
sig_parts = [pub_seed]
|
| 882 |
for i, d in enumerate(digits):
|
|
|
|
| 889 |
if len(pub_key) != cls.PUB_BYTES or len(signature) != cls.SIG_BYTES:
|
| 890 |
return False
|
| 891 |
pub_seed = signature[:32]
|
| 892 |
+
digest = hashlib.shake_256(b"armiger-wots-msg-v2:" + pub_seed + pub_key + message).digest(32)
|
| 893 |
digits = cls._msg_to_base_w(digest)
|
| 894 |
ends: List[bytes] = []
|
| 895 |
for i, d in enumerate(digits):
|
|
|
|
| 909 |
Formula: SHA-256("armiger-tombstone-v1:" || lowercase_hex_infohash)
|
| 910 |
Preserves anonymity while allowing deterministic matching on known bad hashes (128-bit Grover PQ preimage resistance).
|
| 911 |
"""
|
| 912 |
+
clean = str(infohash).strip().lower().encode("utf-8", errors="replace")
|
| 913 |
return hashlib.sha256(b"armiger-tombstone-v1:" + clean).hexdigest()
|
| 914 |
|
| 915 |
|
test_bep44_udp_wire_e2e.py
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""End-to-end 12-node localhost UDP Kademlia BEP-44 / BEP-46 wire harness tests.
|
| 3 |
+
|
| 4 |
+
Verifies:
|
| 5 |
+
1. 12-node localhost (127.0.0.1) UDP Kademlia swarm startup, ping, find_node, and XOR routing.
|
| 6 |
+
2. BEP-44 mutable put/get over UDP with Ed25519 signature verification, monotonic seq, and CAS.
|
| 7 |
+
3. End-to-end ArmigerDHTDatabaseSync encrypted shard publish/fetch/decrypt/Merkle/CSAM/SQLite over UDP.
|
| 8 |
+
4. End-to-end SignedTombstoneList propagation over UDP physically purging revoked records from SQLite.
|
| 9 |
+
5. Wire-level hostile attack rejections:
|
| 10 |
+
- Oversized v (>1000 bytes) -> KRPC error 205
|
| 11 |
+
- Sequence regression (seq <= current) -> KRPC error 302
|
| 12 |
+
- Stale CAS mismatch -> KRPC error 301
|
| 13 |
+
- Forged Ed25519 signature -> KRPC error 301
|
| 14 |
+
- Corrupted BEP-46 ciphertext -> rejected before SQLite ingestion
|
| 15 |
+
"""
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import asyncio
|
| 19 |
+
import hashlib
|
| 20 |
+
import json
|
| 21 |
+
import os
|
| 22 |
+
import secrets
|
| 23 |
+
import time
|
| 24 |
+
import unittest
|
| 25 |
+
import zlib
|
| 26 |
+
|
| 27 |
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
| 28 |
+
|
| 29 |
+
from armiger_dht_db_sync import (
|
| 30 |
+
ArmigerDHTDatabaseSync,
|
| 31 |
+
SubscriptionConfig,
|
| 32 |
+
SyncMode,
|
| 33 |
+
TorrentRecord,
|
| 34 |
+
create_mock_quorum_attestations,
|
| 35 |
+
)
|
| 36 |
+
from bep44_udp_wire_harness import (
|
| 37 |
+
LocalKademliaSwarm,
|
| 38 |
+
bep44_signing_buffer,
|
| 39 |
+
compute_bep44_target,
|
| 40 |
+
udp_query,
|
| 41 |
+
)
|
| 42 |
+
from csam_guard import (
|
| 43 |
+
SignedTombstoneList,
|
| 44 |
+
generate_ed25519_keypair,
|
| 45 |
+
sign_tombstone_list,
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class TestBEP44UDPWireE2E(unittest.TestCase):
|
| 50 |
+
"""End-to-end UDP Kademlia BEP-44 / BEP-46 wire harness test suite."""
|
| 51 |
+
|
| 52 |
+
def test_12_node_udp_swarm_ping_find_node_and_bencode(self) -> None:
|
| 53 |
+
async def _run() -> None:
|
| 54 |
+
swarm = LocalKademliaSwarm(count=12)
|
| 55 |
+
await swarm.start()
|
| 56 |
+
try:
|
| 57 |
+
self.assertEqual(len(swarm.nodes), 12)
|
| 58 |
+
ports = {port for _, port, _ in swarm.nodes}
|
| 59 |
+
self.assertEqual(len(ports), 12)
|
| 60 |
+
|
| 61 |
+
host0, port0, node0 = swarm.nodes[0]
|
| 62 |
+
ping_resp = await udp_query(host0, port0, {b"t": b"p1", b"y": b"q", b"q": b"ping", b"a": {}})
|
| 63 |
+
self.assertEqual(ping_resp[b"y"], b"r")
|
| 64 |
+
self.assertEqual(ping_resp[b"r"][b"id"], node0.node_id)
|
| 65 |
+
|
| 66 |
+
target = hashlib.sha1(b"test-kademlia-target").digest()
|
| 67 |
+
fn_resp = await udp_query(
|
| 68 |
+
host0, port0, {b"t": b"fn", b"y": b"q", b"q": b"find_node", b"a": {b"target": target}}
|
| 69 |
+
)
|
| 70 |
+
self.assertEqual(fn_resp[b"y"], b"r")
|
| 71 |
+
self.assertEqual(len(fn_resp[b"r"][b"nodes_list"]), 8)
|
| 72 |
+
finally:
|
| 73 |
+
swarm.close()
|
| 74 |
+
|
| 75 |
+
asyncio.run(_run())
|
| 76 |
+
|
| 77 |
+
def test_bep44_mutable_put_get_monotonic_seq_and_cas_over_udp(self) -> None:
|
| 78 |
+
async def _run() -> None:
|
| 79 |
+
swarm = LocalKademliaSwarm(count=12)
|
| 80 |
+
await swarm.start()
|
| 81 |
+
try:
|
| 82 |
+
priv_bytes, pub_bytes = generate_ed25519_keypair()
|
| 83 |
+
salt = b"movie/en/hd_1080p/2026_q4"
|
| 84 |
+
|
| 85 |
+
# Publish seq=1 across the 4 closest Kademlia nodes by XOR distance
|
| 86 |
+
res1 = await swarm.put_mutable(priv_bytes, salt, b"manifest-v1", seq=1)
|
| 87 |
+
self.assertTrue(all(r.get(b"y") == b"r" for r in res1))
|
| 88 |
+
|
| 89 |
+
got1 = await swarm.get_mutable(pub_bytes, salt)
|
| 90 |
+
self.assertIsNotNone(got1)
|
| 91 |
+
assert got1 is not None
|
| 92 |
+
self.assertEqual(got1.v, b"manifest-v1")
|
| 93 |
+
self.assertEqual(got1.seq, 1)
|
| 94 |
+
|
| 95 |
+
# Update to seq=2 with matching CAS=1 -> succeeds
|
| 96 |
+
res2 = await swarm.put_mutable(priv_bytes, salt, b"manifest-v2", seq=2, cas=1)
|
| 97 |
+
self.assertTrue(all(r.get(b"y") == b"r" for r in res2))
|
| 98 |
+
|
| 99 |
+
got2 = await swarm.get_mutable(pub_bytes, salt)
|
| 100 |
+
assert got2 is not None
|
| 101 |
+
self.assertEqual(got2.v, b"manifest-v2")
|
| 102 |
+
self.assertEqual(got2.seq, 2)
|
| 103 |
+
finally:
|
| 104 |
+
swarm.close()
|
| 105 |
+
|
| 106 |
+
asyncio.run(_run())
|
| 107 |
+
|
| 108 |
+
def test_e2e_encrypted_shard_sync_and_tombstone_purge_over_udp(self) -> None:
|
| 109 |
+
async def _run() -> None:
|
| 110 |
+
swarm = LocalKademliaSwarm(count=12)
|
| 111 |
+
await swarm.start()
|
| 112 |
+
try:
|
| 113 |
+
root_priv, root_pub = generate_ed25519_keypair()
|
| 114 |
+
shared_secret = hashlib.sha256(b"armiger-udp-e2e-shared-secret").digest()
|
| 115 |
+
shard_id = "movie/en/hd_1080p/2026_q4"
|
| 116 |
+
|
| 117 |
+
sub_cfg = SubscriptionConfig(
|
| 118 |
+
categories={"movie"},
|
| 119 |
+
languages={"en"},
|
| 120 |
+
quality_tiers={"hd_1080p"},
|
| 121 |
+
epochs={"2026_q4"},
|
| 122 |
+
sync_mode=SyncMode.SUBSCRIBE_AND_SEED,
|
| 123 |
+
)
|
| 124 |
+
publisher = ArmigerDHTDatabaseSync(
|
| 125 |
+
subscription=sub_cfg,
|
| 126 |
+
master_secret=shared_secret,
|
| 127 |
+
trusted_root_pubkeys_hex=[root_pub.hex()],
|
| 128 |
+
)
|
| 129 |
+
subscriber = ArmigerDHTDatabaseSync(
|
| 130 |
+
subscription=sub_cfg,
|
| 131 |
+
master_secret=shared_secret,
|
| 132 |
+
trusted_root_pubkeys_hex=[root_pub.hex()],
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
rec1 = TorrentRecord(
|
| 136 |
+
infohash="a1" * 20,
|
| 137 |
+
title="Sintel 2010 1080p BluRay x264",
|
| 138 |
+
category="movie",
|
| 139 |
+
language="en",
|
| 140 |
+
quality_tier="hd_1080p",
|
| 141 |
+
epoch="2026_q4",
|
| 142 |
+
size_bytes=2_100_000_000,
|
| 143 |
+
file_count=2,
|
| 144 |
+
seeders=45,
|
| 145 |
+
leechers=3,
|
| 146 |
+
content_sha256="11" * 32,
|
| 147 |
+
file_list=["Sintel.2010.1080p.mkv", "Sintel.en.srt"],
|
| 148 |
+
safety_gate_passed=True,
|
| 149 |
+
created_at=int(time.time()),
|
| 150 |
+
)
|
| 151 |
+
rec2 = TorrentRecord(
|
| 152 |
+
infohash="b2" * 20,
|
| 153 |
+
title="Tears of Steel 2012 1080p WEB-DL",
|
| 154 |
+
category="movie",
|
| 155 |
+
language="en",
|
| 156 |
+
quality_tier="hd_1080p",
|
| 157 |
+
epoch="2026_q4",
|
| 158 |
+
size_bytes=3_400_000_000,
|
| 159 |
+
file_count=1,
|
| 160 |
+
seeders=80,
|
| 161 |
+
leechers=5,
|
| 162 |
+
content_sha256="22" * 32,
|
| 163 |
+
file_list=["Tears.of.Steel.2012.1080p.mkv"],
|
| 164 |
+
safety_gate_passed=True,
|
| 165 |
+
created_at=int(time.time()),
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
manifest, encrypted_bundle = publisher.build_and_sign_shard(
|
| 169 |
+
shard_id=shard_id,
|
| 170 |
+
records=[rec1, rec2],
|
| 171 |
+
root_privkey=root_priv,
|
| 172 |
+
seq=10,
|
| 173 |
+
)
|
| 174 |
+
manifest = manifest.with_attestations(
|
| 175 |
+
create_mock_quorum_attestations(
|
| 176 |
+
manifest.canonical_digest(),
|
| 177 |
+
[13335, 15169, 16509, 8075, 14061],
|
| 178 |
+
)
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
# Publish over the 12-node UDP swarm
|
| 182 |
+
await swarm.publish_shard_over_udp(manifest, encrypted_bundle, root_priv)
|
| 183 |
+
|
| 184 |
+
# Subscriber fetches via BEP-44 + BEP-46 over UDP and ingests into SQLite
|
| 185 |
+
ok, reason, count = await swarm.fetch_and_apply_shard_over_udp(
|
| 186 |
+
subscriber, root_pub, shard_id
|
| 187 |
+
)
|
| 188 |
+
self.assertTrue(ok, f"Expected UDP shard sync to succeed, got: {reason}")
|
| 189 |
+
self.assertEqual(count, 2)
|
| 190 |
+
self.assertIsNotNone(subscriber.db.get_record(rec1.infohash))
|
| 191 |
+
self.assertIsNotNone(subscriber.db.get_record(rec2.infohash))
|
| 192 |
+
|
| 193 |
+
# Publish a Hybrid Ed25519 + WOTS+-SHAKE256 SignedTombstoneList revoking rec2 over UDP
|
| 194 |
+
signed_tomb = sign_tombstone_list(
|
| 195 |
+
seq=5,
|
| 196 |
+
infohashes_or_blinded=[rec2.infohash],
|
| 197 |
+
private_key_bytes=root_priv,
|
| 198 |
+
)
|
| 199 |
+
tomb_envelope = zlib.compress(
|
| 200 |
+
json.dumps(
|
| 201 |
+
{
|
| 202 |
+
"seq": signed_tomb.seq,
|
| 203 |
+
"timestamp": signed_tomb.timestamp,
|
| 204 |
+
"tombstones": signed_tomb.tombstones,
|
| 205 |
+
"reasons": signed_tomb.reasons,
|
| 206 |
+
"signature_hex": signed_tomb.signature_hex,
|
| 207 |
+
"pq_pubkey_hex": signed_tomb.pq_pubkey_hex,
|
| 208 |
+
"pq_signature_hex": signed_tomb.pq_signature_hex,
|
| 209 |
+
},
|
| 210 |
+
sort_keys=True,
|
| 211 |
+
separators=(",", ":"),
|
| 212 |
+
).encode("utf-8"),
|
| 213 |
+
level=9,
|
| 214 |
+
)
|
| 215 |
+
tomb_ih = await swarm.put_bep46_bundle(tomb_envelope)
|
| 216 |
+
tomb_ptr = json.dumps(
|
| 217 |
+
{"ih": tomb_ih, "sha256": hashlib.sha256(tomb_envelope).hexdigest(), "seq": signed_tomb.seq},
|
| 218 |
+
sort_keys=True,
|
| 219 |
+
separators=(",", ":"),
|
| 220 |
+
).encode("utf-8")
|
| 221 |
+
await swarm.put_mutable(root_priv, b"tombstones/v1", tomb_ptr, seq=signed_tomb.seq)
|
| 222 |
+
|
| 223 |
+
fetched_tomb_ptr = await swarm.get_mutable(root_pub, b"tombstones/v1")
|
| 224 |
+
self.assertIsNotNone(fetched_tomb_ptr)
|
| 225 |
+
assert fetched_tomb_ptr is not None
|
| 226 |
+
ptr_data = json.loads(fetched_tomb_ptr.v.decode("utf-8"))
|
| 227 |
+
fetched_tomb_env = await swarm.get_bep46_bundle(ptr_data["ih"], expected_sha256_hex=ptr_data["sha256"])
|
| 228 |
+
assert fetched_tomb_env is not None
|
| 229 |
+
td = json.loads(zlib.decompress(fetched_tomb_env).decode("utf-8"))
|
| 230 |
+
reconstructed_tomb = SignedTombstoneList(
|
| 231 |
+
seq=td["seq"],
|
| 232 |
+
timestamp=td["timestamp"],
|
| 233 |
+
tombstones=td["tombstones"],
|
| 234 |
+
reasons=td["reasons"],
|
| 235 |
+
signature_hex=td["signature_hex"],
|
| 236 |
+
pq_pubkey_hex=td["pq_pubkey_hex"],
|
| 237 |
+
pq_signature_hex=td["pq_signature_hex"],
|
| 238 |
+
)
|
| 239 |
+
t_ok, t_msg, new_tomb, purged = subscriber.apply_signed_tombstones(reconstructed_tomb)
|
| 240 |
+
self.assertTrue(t_ok, t_msg)
|
| 241 |
+
self.assertEqual(purged, 1)
|
| 242 |
+
self.assertIsNone(subscriber.db.get_record(rec2.infohash))
|
| 243 |
+
self.assertIsNotNone(subscriber.db.get_record(rec1.infohash))
|
| 244 |
+
finally:
|
| 245 |
+
swarm.close()
|
| 246 |
+
|
| 247 |
+
asyncio.run(_run())
|
| 248 |
+
|
| 249 |
+
def test_wire_hostile_attacks_205_301_302_and_corrupted_bundle(self) -> None:
|
| 250 |
+
async def _run() -> None:
|
| 251 |
+
swarm = LocalKademliaSwarm(count=4)
|
| 252 |
+
await swarm.start()
|
| 253 |
+
try:
|
| 254 |
+
host, port, _ = swarm.nodes[0]
|
| 255 |
+
priv_bytes, pub_bytes = generate_ed25519_keypair()
|
| 256 |
+
sk = Ed25519PrivateKey.from_private_bytes(priv_bytes)
|
| 257 |
+
salt = secrets.token_bytes(8)
|
| 258 |
+
|
| 259 |
+
# 1. Oversized v (>1000 bytes) -> KRPC error 205
|
| 260 |
+
big_v = os.urandom(1200)
|
| 261 |
+
big_sig = sk.sign(bep44_signing_buffer(big_v, seq=1, salt=salt))
|
| 262 |
+
resp_205 = await udp_query(
|
| 263 |
+
host,
|
| 264 |
+
port,
|
| 265 |
+
{
|
| 266 |
+
b"t": b"a1",
|
| 267 |
+
b"y": b"q",
|
| 268 |
+
b"q": b"put",
|
| 269 |
+
b"a": {b"k": pub_bytes, b"salt": salt, b"v": big_v, b"seq": 1, b"sig": big_sig},
|
| 270 |
+
},
|
| 271 |
+
)
|
| 272 |
+
self.assertEqual(resp_205.get(b"y"), b"e")
|
| 273 |
+
self.assertEqual(resp_205.get(b"e"), 205)
|
| 274 |
+
|
| 275 |
+
# Store valid seq=5
|
| 276 |
+
v5 = b"valid-seq-5"
|
| 277 |
+
sig5 = sk.sign(bep44_signing_buffer(v5, seq=5, salt=salt))
|
| 278 |
+
resp_ok = await udp_query(
|
| 279 |
+
host,
|
| 280 |
+
port,
|
| 281 |
+
{
|
| 282 |
+
b"t": b"a2",
|
| 283 |
+
b"y": b"q",
|
| 284 |
+
b"q": b"put",
|
| 285 |
+
b"a": {b"k": pub_bytes, b"salt": salt, b"v": v5, b"seq": 5, b"sig": sig5},
|
| 286 |
+
},
|
| 287 |
+
)
|
| 288 |
+
self.assertEqual(resp_ok.get(b"y"), b"r")
|
| 289 |
+
|
| 290 |
+
# 2. Sequence regression (seq=3 <= 5) -> KRPC error 302
|
| 291 |
+
v3 = b"rollback-attempt"
|
| 292 |
+
sig3 = sk.sign(bep44_signing_buffer(v3, seq=3, salt=salt))
|
| 293 |
+
resp_302 = await udp_query(
|
| 294 |
+
host,
|
| 295 |
+
port,
|
| 296 |
+
{
|
| 297 |
+
b"t": b"a3",
|
| 298 |
+
b"y": b"q",
|
| 299 |
+
b"q": b"put",
|
| 300 |
+
b"a": {b"k": pub_bytes, b"salt": salt, b"v": v3, b"seq": 3, b"sig": sig3},
|
| 301 |
+
},
|
| 302 |
+
)
|
| 303 |
+
self.assertEqual(resp_302.get(b"y"), b"e")
|
| 304 |
+
self.assertEqual(resp_302.get(b"e"), 302)
|
| 305 |
+
|
| 306 |
+
# 3. Stale CAS (cas=2 != current seq=5) -> KRPC error 301
|
| 307 |
+
v6 = b"cas-mismatch-attempt"
|
| 308 |
+
sig6 = sk.sign(bep44_signing_buffer(v6, seq=6, salt=salt, cas=2))
|
| 309 |
+
resp_301_cas = await udp_query(
|
| 310 |
+
host,
|
| 311 |
+
port,
|
| 312 |
+
{
|
| 313 |
+
b"t": b"a4",
|
| 314 |
+
b"y": b"q",
|
| 315 |
+
b"q": b"put",
|
| 316 |
+
b"a": {b"k": pub_bytes, b"salt": salt, b"v": v6, b"seq": 6, b"cas": 2, b"sig": sig6},
|
| 317 |
+
},
|
| 318 |
+
)
|
| 319 |
+
self.assertEqual(resp_301_cas.get(b"y"), b"e")
|
| 320 |
+
self.assertEqual(resp_301_cas.get(b"e"), 301)
|
| 321 |
+
|
| 322 |
+
# 4. Forged Ed25519 signature -> KRPC error 301
|
| 323 |
+
other_priv, _ = generate_ed25519_keypair()
|
| 324 |
+
other_sk = Ed25519PrivateKey.from_private_bytes(other_priv)
|
| 325 |
+
forged_sig = other_sk.sign(bep44_signing_buffer(b"forged", seq=10, salt=salt))
|
| 326 |
+
resp_301_sig = await udp_query(
|
| 327 |
+
host,
|
| 328 |
+
port,
|
| 329 |
+
{
|
| 330 |
+
b"t": b"a5",
|
| 331 |
+
b"y": b"q",
|
| 332 |
+
b"q": b"put",
|
| 333 |
+
b"a": {b"k": pub_bytes, b"salt": salt, b"v": b"forged", b"seq": 10, b"sig": forged_sig},
|
| 334 |
+
},
|
| 335 |
+
)
|
| 336 |
+
self.assertEqual(resp_301_sig.get(b"y"), b"e")
|
| 337 |
+
self.assertEqual(resp_301_sig.get(b"e"), 301)
|
| 338 |
+
|
| 339 |
+
# Verify stored value is still untouched at seq=5
|
| 340 |
+
target = compute_bep44_target(pub_bytes, salt)
|
| 341 |
+
got = await udp_query(host, port, {b"t": b"g", b"y": b"q", b"q": b"get", b"a": {b"target": target}})
|
| 342 |
+
self.assertEqual(got.get(b"v"), b"valid-seq-5")
|
| 343 |
+
self.assertEqual(got.get(b"seq"), 5)
|
| 344 |
+
finally:
|
| 345 |
+
swarm.close()
|
| 346 |
+
|
| 347 |
+
asyncio.run(_run())
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
if __name__ == "__main__":
|
| 351 |
+
unittest.main()
|
test_dht_db_sync.py
CHANGED
|
@@ -825,7 +825,7 @@ class TestEndToEndDHTSyncEngine(unittest.TestCase):
|
|
| 825 |
shard_id = "anime/ja/hd_1080p/2026_q4"
|
| 826 |
# A shard contains 1 valid record and 1 disguised malware dropper
|
| 827 |
rec_clean = TorrentRecord(
|
| 828 |
-
infohash="
|
| 829 |
title="Clean Anime Movie",
|
| 830 |
category="anime",
|
| 831 |
language="ja",
|
|
@@ -841,7 +841,7 @@ class TestEndToEndDHTSyncEngine(unittest.TestCase):
|
|
| 841 |
created_at=100,
|
| 842 |
)
|
| 843 |
rec_malware = TorrentRecord(
|
| 844 |
-
infohash="
|
| 845 |
title="Clean Anime Movie Part 2",
|
| 846 |
category="anime",
|
| 847 |
language="ja",
|
|
|
|
| 825 |
shard_id = "anime/ja/hd_1080p/2026_q4"
|
| 826 |
# A shard contains 1 valid record and 1 disguised malware dropper
|
| 827 |
rec_clean = TorrentRecord(
|
| 828 |
+
infohash="c1ea0" + "0" * 35,
|
| 829 |
title="Clean Anime Movie",
|
| 830 |
category="anime",
|
| 831 |
language="ja",
|
|
|
|
| 841 |
created_at=100,
|
| 842 |
)
|
| 843 |
rec_malware = TorrentRecord(
|
| 844 |
+
infohash="bad0000" + "0" * 33,
|
| 845 |
title="Clean Anime Movie Part 2",
|
| 846 |
category="anime",
|
| 847 |
language="ja",
|