weed-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
node.py ADDED
@@ -0,0 +1,784 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ The missing integration piece: a real node that hosts, discovers, and
4
+ downloads — not another isolated demo. Wires together pieces already built
5
+ tonight rather than reinventing them:
6
+
7
+ - real chunk-serve protocol, extended from poc_network_challenge.py's
8
+ holder (adds INFO/LEAVES so a downloader can learn the archive's shape
9
+ before fetching)
10
+ - real ott archives (same .ott/ format poc_real_archive_challenge.py
11
+ read from, via `pip install btcvm`)
12
+ - real signed events (Identity/sign_event from poc_reputation.py), now
13
+ persisted to disk instead of regenerated fresh every run — a real node
14
+ needs a stable identity across invocations
15
+ - the same discovery relay protocol from discovery_relay.py
16
+
17
+ What's actually new here, not just wired: a real client-driven download —
18
+ every previous script verified chunks locally or over a network, none of
19
+ them reassembled a full file from a remote peer onto disk before this.
20
+ """
21
+ import base64
22
+ import hashlib
23
+ import json
24
+ import os
25
+ import random
26
+ import socket
27
+ import ssl
28
+ import sys
29
+ import threading
30
+ import time
31
+ import urllib.request
32
+
33
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
34
+ from poc_reputation import Identity, verify_attestation, attestation_id
35
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
36
+ from cryptography.hazmat.primitives import serialization
37
+
38
+ IDENTITY_PATH = os.path.expanduser('~/.weed_identity.key')
39
+ IDENTITY_ARMOR_HEADER = '-----BEGIN WEED IDENTITY KEY-----'
40
+ IDENTITY_ARMOR_FOOTER = '-----END WEED IDENTITY KEY-----'
41
+
42
+
43
+ def _armor_identity(raw_bytes):
44
+ """Base64 text with a header/footer — NOT real OpenPGP armor (no
45
+ CRC24, no packet framing), and not encryption: this only changes how
46
+ the same private key bytes are encoded on disk, from opaque binary
47
+ (`file` calls it "data") to something readable/diffable/copy-
48
+ pasteable. A weed-specific label on purpose, not a PGP one — this
49
+ repo already decided against adopting the real OpenPGP format (see
50
+ README's "Transitive trust" section), so nothing here should look
51
+ like it's actually PGP-compatible."""
52
+ b64 = base64.b64encode(raw_bytes).decode()
53
+ lines = [b64[i:i + 64] for i in range(0, len(b64), 64)]
54
+ return IDENTITY_ARMOR_HEADER + '\n' + '\n'.join(lines) + '\n' + IDENTITY_ARMOR_FOOTER + '\n'
55
+
56
+
57
+ def _dearmor_identity(text):
58
+ lines = text.strip().splitlines()
59
+ if len(lines) < 2 or lines[0].strip() != IDENTITY_ARMOR_HEADER \
60
+ or lines[-1].strip() != IDENTITY_ARMOR_FOOTER:
61
+ raise ValueError('not a weed-armored identity key')
62
+ return base64.b64decode(''.join(lines[1:-1]))
63
+
64
+
65
+ def load_or_create_identity():
66
+ """A real node needs a stable pubkey across runs — regenerating fresh
67
+ every invocation (like every other script tonight) would mean nobody
68
+ could ever build reputation or subscribe to a host's key for real."""
69
+ identity = Identity('local')
70
+ if os.path.exists(IDENTITY_PATH):
71
+ with open(IDENTITY_PATH, 'rb') as f:
72
+ file_bytes = f.read()
73
+ try:
74
+ key_bytes = _dearmor_identity(file_bytes.decode())
75
+ except (UnicodeDecodeError, ValueError):
76
+ # pre-armor identity file (raw 32 bytes, no wrapper) — same key,
77
+ # just not encoded yet. Re-armor it in place so this only ever
78
+ # has to happen once; the underlying private key bytes (and
79
+ # therefore the pubkey) are untouched.
80
+ key_bytes = file_bytes
81
+ with open(IDENTITY_PATH, 'w') as f:
82
+ f.write(_armor_identity(key_bytes))
83
+ os.chmod(IDENTITY_PATH, 0o600)
84
+ identity._priv = Ed25519PrivateKey.from_private_bytes(key_bytes)
85
+ identity.pub = identity._priv.public_key()
86
+ else:
87
+ key_bytes = identity._priv.private_bytes(
88
+ serialization.Encoding.Raw, serialization.PrivateFormat.Raw,
89
+ serialization.NoEncryption())
90
+ with open(IDENTITY_PATH, 'w') as f:
91
+ f.write(_armor_identity(key_bytes))
92
+ os.chmod(IDENTITY_PATH, 0o600)
93
+ return identity
94
+
95
+
96
+ # ── wire protocol — text command line, JSON/base64 bodies where needed ─────
97
+
98
+ def recv_line(sock):
99
+ buf = b''
100
+ while not buf.endswith(b'\n'):
101
+ chunk = sock.recv(65536)
102
+ if not chunk:
103
+ break
104
+ buf += chunk
105
+ return buf.decode().strip()
106
+
107
+
108
+ class LineReader:
109
+ """Buffered line reader for a connection that can receive more than
110
+ one newline-terminated message per recv() — recv_line above assumes
111
+ exactly one line arrives per call, true everywhere else in this
112
+ protocol (strict request/response, never pipelined) but not true for
113
+ the tunnel control channel: if two downloaders CONNECT to
114
+ tunnel_relay.py around the same time, both NEWSTREAM messages can
115
+ legitimately land in a single recv(), and recv_line would silently
116
+ fold both into one malformed line and drop the second one."""
117
+
118
+ def __init__(self, sock):
119
+ self.sock = sock
120
+ self.buf = b''
121
+
122
+ def readline(self):
123
+ while b'\n' not in self.buf:
124
+ chunk = self.sock.recv(4096)
125
+ if not chunk:
126
+ line, self.buf = self.buf, b''
127
+ return line.decode().strip()
128
+ self.buf += chunk
129
+ line, self.buf = self.buf.split(b'\n', 1)
130
+ return line.decode().strip()
131
+
132
+
133
+ def find_manifest_entry(archive_dir, file_name=None):
134
+ archive_dir = os.path.expanduser(archive_dir) # os.path.join never expands ~, it stays literal
135
+ manifest_path = os.path.join(archive_dir, '.ott', 'manifest.jsonl')
136
+ if not os.path.exists(manifest_path):
137
+ sys.exit(f"no .ott/manifest.jsonl in {archive_dir} — archive a file with ott first")
138
+ with open(manifest_path) as f:
139
+ entries = [json.loads(line) for line in f if line.strip()]
140
+ if file_name:
141
+ entries = [e for e in entries if e['name'] == file_name]
142
+ if not entries:
143
+ sys.exit(f"no archived file found in {archive_dir}" + (f" matching {file_name}" if file_name else ""))
144
+ return entries[-1] # last-write-wins, same convention ott itself uses
145
+
146
+
147
+ def load_manifest_entries(archive_dir, file_name=None):
148
+ """Every distinct file in the archive, not just one — find_manifest_entry
149
+ collapses to a single entries[-1], which is exactly why `host <dir>` with
150
+ no --file only ever served the single most-recently-added file out of a
151
+ 45-video archive. Dedupes by name (last-write-wins, same convention)."""
152
+ archive_dir = os.path.expanduser(archive_dir)
153
+ manifest_path = os.path.join(archive_dir, '.ott', 'manifest.jsonl')
154
+ if not os.path.exists(manifest_path):
155
+ sys.exit(f"no .ott/manifest.jsonl in {archive_dir} — archive a file with ott first")
156
+ with open(manifest_path) as f:
157
+ raw = [json.loads(line) for line in f if line.strip()]
158
+ if file_name:
159
+ raw = [e for e in raw if e['name'] == file_name]
160
+ by_name = {}
161
+ for e in raw:
162
+ by_name[e['name']] = e
163
+ entries = list(by_name.values())
164
+ if not entries:
165
+ sys.exit(f"no archived file found in {archive_dir}" + (f" matching {file_name}" if file_name else ""))
166
+ return entries
167
+
168
+
169
+ def load_leaves(archive_dir, root_hash):
170
+ archive_dir = os.path.expanduser(archive_dir)
171
+ chunks_path = os.path.join(archive_dir, '.ott', 'chunks', f'{root_hash}.json')
172
+ if not os.path.exists(chunks_path):
173
+ sys.exit(f"no chunks file at {chunks_path} — the manifest entry for this hash has no "
174
+ f"matching chunk data in this archive_dir (stale/mismatched .ott/ state, or "
175
+ f"this isn't the archive_dir that file was actually added from)")
176
+ with open(chunks_path) as f:
177
+ return json.load(f)
178
+
179
+
180
+ def serve_session(conn, entries_by_hash, default_hash, price):
181
+ """Handle every command on one connection, not just one — a download
182
+ needs INFO + LEAVES + one FETCH per chunk (thousands, for a real
183
+ archive), and reconnecting per command is what makes a tunneled
184
+ connection (see tunnel_relay.py) pay a full relay rendezvous per
185
+ chunk instead of once per session. Shared by the direct accept() loop
186
+ below and by run_host_tunnel's per-stream data connections.
187
+
188
+ entries_by_hash lets one server (one port) hold more than one file —
189
+ a downloader picks which via SELECT <content_hash_or_prefix> before
190
+ anything else. default_hash (set only when the host has exactly one
191
+ file) means a single-file host never needs SELECT at all, so the
192
+ `download --from host:port` discovery-skipping escape hatch and the
193
+ tunnel path (already scoped to one file before this function runs)
194
+ keep working unchanged."""
195
+ selected = default_hash
196
+ with conn:
197
+ while True:
198
+ line = recv_line(conn)
199
+ if not line:
200
+ break
201
+ parts = line.split()
202
+ if not parts:
203
+ continue
204
+ if parts[0] == 'SELECT':
205
+ match = next((h for h in entries_by_hash if len(parts) > 1 and h.startswith(parts[1])), None)
206
+ if match:
207
+ selected = match
208
+ conn.sendall(b'OK\n')
209
+ else:
210
+ conn.sendall(b'ERR unknown content hash\n')
211
+ continue
212
+ if selected is None:
213
+ conn.sendall(b'ERR this host serves more than one file '
214
+ b'- send SELECT <content_hash> first\n')
215
+ continue
216
+ entry, leaves, file_path = entries_by_hash[selected]
217
+ if parts[0] == 'INFO':
218
+ conn.sendall((json.dumps({
219
+ 'name': entry['name'], 'sha256': entry['sha256'], 'size': entry['size'],
220
+ 'n_chunks': entry['n_chunks'], 'chunk_size': entry['chunk_size'],
221
+ }) + '\n').encode())
222
+ elif parts[0] == 'LEAVES':
223
+ conn.sendall((json.dumps(leaves) + '\n').encode())
224
+ elif parts[0] == 'CHALLENGE':
225
+ idx, nonce_hex = int(parts[1]), parts[2]
226
+ with open(file_path, 'rb') as f:
227
+ f.seek(idx * entry['chunk_size'])
228
+ data = f.read(entry['chunk_size'])
229
+ h = hashlib.sha256(data + bytes.fromhex(nonce_hex)).hexdigest()
230
+ conn.sendall(f'HASH {h}\n'.encode())
231
+ elif parts[0] == 'FETCH':
232
+ idx = int(parts[1])
233
+ with open(file_path, 'rb') as f:
234
+ f.seek(idx * entry['chunk_size'])
235
+ data = f.read(entry['chunk_size'])
236
+ conn.sendall((f'DATA {base64.b64encode(data).decode()}\n').encode())
237
+ elif parts[0] == 'PRICE':
238
+ conn.sendall(f'PRICE {price}\n'.encode())
239
+
240
+
241
+ def run_host_server(archive_dir, file_name, port, bind_host='0.0.0.0', quiet=False, price=0):
242
+ archive_dir = os.path.expanduser(archive_dir)
243
+ entries = load_manifest_entries(archive_dir, file_name)
244
+ entries_by_hash = {}
245
+ for entry in entries:
246
+ leaves = load_leaves(archive_dir, entry['sha256'])
247
+ file_path = entry.get('last_path') or os.path.join(archive_dir, entry['name'])
248
+ if not os.path.exists(file_path):
249
+ sys.exit(f"archived file not found on disk at {file_path}")
250
+ entries_by_hash[entry['sha256']] = (entry, leaves, file_path)
251
+ default_hash = next(iter(entries_by_hash)) if len(entries_by_hash) == 1 else None
252
+
253
+ srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
254
+ srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
255
+ srv.bind((bind_host, port))
256
+ srv.listen(8)
257
+ if not quiet:
258
+ # a background thread's print() races with cmd.Cmd's input()-driven
259
+ # prompt on the same stdout — see run_relay_server's docstring for
260
+ # why the shell passes quiet=True instead of patching this visually
261
+ if default_hash:
262
+ entry = entries[0]
263
+ print(f"[host:{port}] serving {entry['name']} ({entry['size']:,} bytes, "
264
+ f"{entry['n_chunks']} chunks x {entry['chunk_size']} bytes)")
265
+ print(f"[host:{port}] sha256/merkle root: {entry['sha256']}")
266
+ else:
267
+ total_size = sum(e['size'] for e in entries)
268
+ print(f"[host:{port}] serving {len(entries)} files ({total_size:,} bytes total) "
269
+ f"— clients SELECT which one by content hash")
270
+
271
+ while True:
272
+ conn, _ = srv.accept()
273
+ # a whole download now lives on one connection (see serve_session) —
274
+ # accept() must hand off to a thread per connection, or one session
275
+ # would block every other downloader until it finished
276
+ threading.Thread(target=serve_session, args=(conn, entries_by_hash, default_hash, price),
277
+ daemon=True).start()
278
+
279
+
280
+ def _connect_tunnel_socket(relay_host, relay_port, use_tls):
281
+ """Plain TCP to the tunnel relay, or TLS-wrapped if the relay
282
+ terminates TLS at the edge (e.g. Fly's `handlers = ["tls"]`) — the
283
+ relay process itself (tunnel_relay.py) never sees or needs to know
284
+ about TLS either way, since edge termination decrypts before
285
+ forwarding to it. Only the two ends actually crossing the public
286
+ internet need this: a host's REGISTER/DATA connections here, and a
287
+ downloader's CONNECT in HostConnection.connect_via_tunnel."""
288
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
289
+ sock.connect((relay_host, relay_port))
290
+ if use_tls:
291
+ ctx = ssl.create_default_context()
292
+ sock = ctx.wrap_socket(sock, server_hostname=relay_host)
293
+ return sock
294
+
295
+
296
+ def run_host_tunnel(relay_host, relay_port, token, entry, leaves, file_path, price,
297
+ use_tls=False, quiet=False, heartbeat_interval=45):
298
+ """NAT-traversal path: instead of (or alongside) binding a locally
299
+ reachable port, register with a tunnel_relay.py relay and serve every
300
+ downloader it pairs us with. One persistent CONTROL connection stays
301
+ open for the lifetime of hosting; each real downloader gets its own
302
+ DATA connection, dialed back to the relay on demand (NEWSTREAM), so
303
+ concurrent tunneled downloads don't block each other.
304
+
305
+ The control connection sends nothing at all between REGISTER and the
306
+ first real NEWSTREAM — which can be minutes or hours if no one
307
+ downloads in the meantime. Real-world proxies in the middle (observed:
308
+ Fly's own edge) reset TCP connections that go idle for a few minutes,
309
+ which silently unregisters the host with no error on this end until
310
+ the next download attempt fails. A small periodic heartbeat keeps the
311
+ connection looking active; tunnel_relay.py's REGISTER loop already
312
+ discards anything it receives that isn't relevant to it (it only ever
313
+ checks for EOF), so this needs zero changes on the relay side."""
314
+ entries_by_hash = {entry['sha256']: (entry, leaves, file_path)}
315
+ ctrl = _connect_tunnel_socket(relay_host, relay_port, use_tls)
316
+ ctrl.sendall(f'REGISTER {token}\n'.encode())
317
+ if not quiet:
318
+ tls_note = ' (tls)' if use_tls else ''
319
+ print(f"[tunnel] registered {token[:16]}... with relay {relay_host}:{relay_port}{tls_note}")
320
+
321
+ stop_heartbeat = threading.Event()
322
+
323
+ def send_heartbeats():
324
+ while not stop_heartbeat.wait(heartbeat_interval):
325
+ try:
326
+ ctrl.sendall(b'PING\n')
327
+ except OSError:
328
+ return
329
+
330
+ threading.Thread(target=send_heartbeats, daemon=True).start()
331
+
332
+ try:
333
+ reader = LineReader(ctrl)
334
+ while True:
335
+ line = reader.readline()
336
+ if not line:
337
+ if not quiet:
338
+ print(f"[tunnel] control connection to {relay_host}:{relay_port} closed")
339
+ return
340
+ parts = line.split()
341
+ if parts and parts[0] == 'NEWSTREAM':
342
+ stream_id = parts[1]
343
+ data_conn = _connect_tunnel_socket(relay_host, relay_port, use_tls)
344
+ data_conn.sendall(f'DATA {stream_id}\n'.encode())
345
+ threading.Thread(target=serve_session,
346
+ args=(data_conn, entries_by_hash, entry['sha256'], price),
347
+ daemon=True).start()
348
+ # anything else (notably our own echoed-back nothing -- PING
349
+ # is one-directional, the relay never echoes it) is silently
350
+ # ignored, same as it always was
351
+ finally:
352
+ stop_heartbeat.set()
353
+
354
+
355
+ # ── client side ──────────────────────────────────────────────────────────
356
+
357
+ class HostConnection:
358
+ """One persistent socket carrying every command for a session, direct
359
+ or tunneled — see serve_session's docstring for why reconnecting per
360
+ command (the old behavior) is untenable once a tunnel relay is in the
361
+ path."""
362
+
363
+ def __init__(self, sock):
364
+ self.sock = sock
365
+
366
+ @classmethod
367
+ def connect_direct(cls, host, port, timeout=10):
368
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
369
+ sock.settimeout(timeout)
370
+ sock.connect((host, port))
371
+ return cls(sock)
372
+
373
+ @classmethod
374
+ def connect_via_tunnel(cls, relay_host, relay_port, token, use_tls=False, timeout=10):
375
+ """token is the content_hash the host registered under (see
376
+ run_host_tunnel) — the tunnel relay has zero opinion on content,
377
+ it just pairs this CONNECT with that host's next NEWSTREAM."""
378
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
379
+ sock.settimeout(timeout)
380
+ sock.connect((relay_host, relay_port))
381
+ if use_tls:
382
+ ctx = ssl.create_default_context()
383
+ sock = ctx.wrap_socket(sock, server_hostname=relay_host)
384
+ sock.sendall(f'CONNECT {token}\n'.encode())
385
+ return cls(sock)
386
+
387
+ def request(self, line):
388
+ self.sock.sendall((line + '\n').encode())
389
+ return recv_line(self.sock)
390
+
391
+ def close(self):
392
+ self.sock.close()
393
+
394
+ def __enter__(self):
395
+ return self
396
+
397
+ def __exit__(self, *exc_info):
398
+ self.close()
399
+
400
+
401
+ def _parse_tunnel(tunnel_addr):
402
+ """'relay_host:relay_port' or 'tls://relay_host:relay_port' ->
403
+ (relay_host, relay_port, use_tls), or None. The tls:// prefix marks a
404
+ relay that terminates TLS at the edge (e.g. Fly's `handlers =
405
+ ["tls"]`) — publish() stores this string as given, so a downloader
406
+ who discovers the host gets the same tls:// marker automatically and
407
+ connects the same way the host registered."""
408
+ if not tunnel_addr:
409
+ return None
410
+ use_tls = tunnel_addr.startswith('tls://')
411
+ if use_tls:
412
+ tunnel_addr = tunnel_addr[len('tls://'):]
413
+ if ':' not in tunnel_addr:
414
+ raise ValueError(f"--tunnel {tunnel_addr!r} is missing a port — expected "
415
+ f"[tls://]host:port, e.g. tls://tunnel.hak4.org:9199")
416
+ relay_host, relay_port = tunnel_addr.rsplit(':', 1)
417
+ if not relay_port.isdigit():
418
+ raise ValueError(f"--tunnel port {relay_port!r} isn't a number — expected "
419
+ f"[tls://]host:port, e.g. tls://tunnel.hak4.org:9199")
420
+ return relay_host, int(relay_port), use_tls
421
+
422
+
423
+ def open_connection(host_addr, tunnel=None, content_hash=None, timeout=10):
424
+ """host_addr is 'host:port' — used directly unless tunnel is given, in
425
+ which case it's ignored and content_hash is used as the tunnel
426
+ rendezvous token instead (the host isn't reachable at host_addr at
427
+ all in that case). tunnel is a pre-parsed _parse_tunnel() result."""
428
+ if tunnel:
429
+ if not content_hash:
430
+ raise ValueError("tunnel connection requires content_hash as the rendezvous token")
431
+ relay_host, relay_port, use_tls = tunnel
432
+ return HostConnection.connect_via_tunnel(relay_host, relay_port, content_hash,
433
+ use_tls=use_tls, timeout=timeout)
434
+ host, port_s = host_addr.rsplit(':', 1)
435
+ return HostConnection.connect_direct(host, int(port_s), timeout=timeout)
436
+
437
+
438
+ def download(host_addr, out_path, tunnel=None, content_hash=None, on_progress=None):
439
+ from ott import merkle_root # pip install btcvm
440
+
441
+ out_path = os.path.expanduser(out_path)
442
+ via = f"tunnel {tunnel[0]}:{tunnel[1]}" if tunnel else host_addr
443
+ with open_connection(host_addr, tunnel=tunnel, content_hash=content_hash) as conn:
444
+ if content_hash and not tunnel:
445
+ # tunnel connections are already scoped to one file by the relay's
446
+ # rendezvous token (see run_host_tunnel) — SELECT is only needed
447
+ # against a direct multi-file host, which may be serving more than
448
+ # just this content_hash on the same port
449
+ sel = conn.request(f'SELECT {content_hash}')
450
+ if sel != 'OK':
451
+ sys.exit(f"host at {host_addr} rejected SELECT {content_hash[:16]}...: {sel}")
452
+ info = json.loads(conn.request('INFO'))
453
+ print(f"downloading {info['name']} ({info['size']:,} bytes, {info['n_chunks']} chunks) "
454
+ f"from {via}")
455
+ leaves = json.loads(conn.request('LEAVES'))
456
+ if len(leaves) != info['n_chunks']:
457
+ sys.exit(f"host's LEAVES count ({len(leaves)}) doesn't match its own INFO "
458
+ f"({info['n_chunks']}) — refusing to trust an inconsistent host")
459
+
460
+ # ott records a VIDEO's sha256 field as the Merkle root over its chunk
461
+ # hashes, not a linear whole-file hash (see cmd_add: `digest =
462
+ # merkle_root(chunks)`) — verify that BEFORE downloading a single byte,
463
+ # so a host can't serve a self-consistent-but-fake leaves list.
464
+ recomputed_root = merkle_root(leaves)
465
+ if recomputed_root != info['sha256']:
466
+ sys.exit(f"host's LEAVES don't Merkle-root to its own advertised sha256 "
467
+ f"({recomputed_root[:16]}... != {info['sha256'][:16]}...) — "
468
+ f"refusing to download from a host lying about its own archive")
469
+
470
+ t0 = time.time()
471
+ with open(out_path, 'wb') as out:
472
+ for idx in range(info['n_chunks']):
473
+ resp = conn.request(f'FETCH {idx}')
474
+ if not resp.startswith('DATA '):
475
+ sys.exit(f"chunk {idx}: bad response from host: {resp[:80]}")
476
+ data = base64.b64decode(resp[5:])
477
+ leaf = hashlib.sha256(data).hexdigest()
478
+ if leaf != leaves[idx]:
479
+ sys.exit(f"chunk {idx}: hash mismatch — host sent bytes that don't match "
480
+ f"its own committed chunk hash. aborting download, not writing a "
481
+ f"corrupted/tampered file.")
482
+ out.write(data)
483
+ if on_progress:
484
+ on_progress(idx, info['n_chunks'])
485
+ if idx % 200 == 0 or idx == info['n_chunks'] - 1:
486
+ print(f" chunk {idx + 1}/{info['n_chunks']} verified", end='\r', flush=True)
487
+ elapsed = time.time() - t0
488
+ actual_size = os.path.getsize(out_path)
489
+ print(f"\n{info['n_chunks']} chunks downloaded and verified in {elapsed:.1f}s")
490
+ print("Merkle root over received chunks matches host's advertised sha256: True "
491
+ "(checked before downloading, not after)")
492
+ if actual_size != info['size']:
493
+ os.remove(out_path)
494
+ sys.exit(f"size mismatch: wrote {actual_size:,} bytes, host advertised {info['size']:,} "
495
+ f"— deleted the output, do not trust this download")
496
+ return out_path
497
+
498
+
499
+ # ── possession challenge + price auction, wired to real discovery ───────
500
+ #
501
+ # Everything below stitches poc_challenge_auction.py (challenge-gate a
502
+ # reverse auction), poc_reputation.py (local trust score), and
503
+ # lightning_settle.py (real HTLC settlement) into the real download path —
504
+ # none of them were reachable from `download()` before this, which meant
505
+ # `discover` -> `download` would silently trust the first host found, for
506
+ # free, with zero possession check.
507
+ #
508
+ # Scoped honestly, not silently: this uses sample-FETCH + Merkle-proof
509
+ # verification (poc_challenge_auction.py Part 1's mechanism) to prove a
510
+ # host holds real chunks, not the nonce-salted timing challenge from Part
511
+ # 2 — that one specifically detects a RELAY masquerading as a holder, but
512
+ # needs ground-truth bytes the verifier already trusts, which a first-time
513
+ # downloader doesn't have yet (that's the whole point of downloading).
514
+ # Sample-verifying a few chunks via FETCH is what a fresh client can
515
+ # actually do independently, since LEAVES is already Merkle-root-checked
516
+ # against the host's own advertised sha256.
517
+
518
+ def sample_challenge(conn, leaves, k=3):
519
+ """Fetch k random chunks over the given (already-open) connection and
520
+ verify each against the (already Merkle-verified) leaves list —
521
+ proves *this specific host* truly holds real chunks, not just that
522
+ someone somewhere does."""
523
+ n = len(leaves)
524
+ indices = random.sample(range(n), min(k, n))
525
+ latencies = []
526
+ for idx in indices:
527
+ t0 = time.perf_counter()
528
+ try:
529
+ resp = conn.request(f'FETCH {idx}')
530
+ except OSError:
531
+ return False, latencies
532
+ latencies.append((time.perf_counter() - t0) * 1000)
533
+ if not resp.startswith('DATA '):
534
+ return False, latencies
535
+ data = base64.b64decode(resp[5:])
536
+ if hashlib.sha256(data).hexdigest() != leaves[idx]:
537
+ return False, latencies
538
+ return True, latencies
539
+
540
+
541
+ def get_price(conn):
542
+ try:
543
+ resp = conn.request('PRICE')
544
+ except OSError:
545
+ return 0
546
+ if resp.startswith('PRICE '):
547
+ try:
548
+ return int(resp[6:])
549
+ except ValueError:
550
+ return 0
551
+ return 0 # host doesn't implement PRICE — treat as free rather than failing
552
+
553
+
554
+ def discover_hosts_for(relay_urls, content_hash):
555
+ """Every host that published a matching content_hash — real multi-host
556
+ resolution (discover() already dedupes by event, not by content, so
557
+ two publishers of the same file both show up here)."""
558
+ return [p for p in discover(relay_urls) if p['content_hash'].startswith(content_hash)]
559
+
560
+
561
+ def build_trust_graph(subscribe_events, root_pubkey, max_hops=3, decay=0.5):
562
+ """Transitive trust, computed here instead of adopting PGP's standard
563
+ to get it: BFS outward from root_pubkey through real signed subscribe
564
+ events (each one a real trust edge — 'I subscribe to X' = 'I trust
565
+ X'). Trust decays per hop, same shape as PGP's marginal-vs-full
566
+ distinction (a direct subscribe counts more than a friend-of-a-
567
+ friend), computed on the Ed25519 events already in place rather than
568
+ needing the OpenPGP format at all.
569
+
570
+ Takes the *shortest* path to each reachable pubkey (first time BFS
571
+ reaches it), not the sum across every path that reaches it — summing
572
+ would let a sybil ring inflate a target's trust just by adding more
573
+ low-value paths to it, which defeats the point of a hop-decayed graph
574
+ in the first place."""
575
+ edges = {}
576
+ for e in subscribe_events:
577
+ p = e['payload']
578
+ edges.setdefault(p['signer_pubkey'], set()).add(p['target_pubkey'])
579
+
580
+ trust = {}
581
+ frontier = [root_pubkey]
582
+ seen = {root_pubkey}
583
+ hop = 0
584
+ while frontier and hop < max_hops:
585
+ hop += 1
586
+ weight = decay ** hop
587
+ next_frontier = []
588
+ for node in frontier:
589
+ for target in edges.get(node, ()):
590
+ if target not in seen:
591
+ seen.add(target)
592
+ trust[target] = weight
593
+ next_frontier.append(target)
594
+ frontier = next_frontier
595
+ return trust
596
+
597
+
598
+ def select_host(candidates, k=3, reputation=None, trust_in_signers=None):
599
+ """Gate on real possession, then rank survivors by reputation then
600
+ price — same 'challenge gates the auction' shape as
601
+ poc_challenge_auction.py's naive-vs-gated comparison. trust_in_signers
602
+ (from build_trust_graph) lets a candidate with zero *direct* history
603
+ still score above 0 if someone in the caller's transitive trust graph
604
+ has attested to it."""
605
+ from ott import merkle_root
606
+
607
+ scored = []
608
+ for c in candidates:
609
+ tunnel = _parse_tunnel(c.get('tunnel'))
610
+ try:
611
+ with open_connection(c['host'], tunnel=tunnel, content_hash=c['content_hash']) as conn:
612
+ if not tunnel:
613
+ sel = conn.request(f"SELECT {c['content_hash']}")
614
+ if sel != 'OK':
615
+ print(f" x {c['host']}: SELECT rejected ({sel}) — skipping")
616
+ continue
617
+ leaves = json.loads(conn.request('LEAVES'))
618
+ info = json.loads(conn.request('INFO'))
619
+ if merkle_root(leaves) != info['sha256'] or info['sha256'] != c['content_hash']:
620
+ print(f" x {c['host']}: advertised content doesn't match its own LEAVES/INFO — skipping")
621
+ continue
622
+ passed, latencies = sample_challenge(conn, leaves, k=k)
623
+ if not passed:
624
+ print(f" x {c['host']}: failed possession challenge ({k} chunks sampled) — skipping")
625
+ continue
626
+ price = get_price(conn)
627
+ except OSError as e:
628
+ print(f" x {c['host']}: unreachable ({e})")
629
+ continue
630
+ rep_score, rep_why = (reputation.trust_score(c['signer_pubkey'], trust_in_signers)
631
+ if reputation else (0.5, 'no reputation store'))
632
+ avg_latency = sum(latencies) / len(latencies) if latencies else 0.0
633
+ print(f" + {c['host']}: possession verified ({k}/{k} chunks), price={price} sat, "
634
+ f"reputation={rep_score:.2f} ({rep_why}), avg_latency={avg_latency:.1f}ms")
635
+ scored.append({'candidate': c, 'info': info, 'price': price,
636
+ 'reputation': rep_score, 'avg_latency_ms': avg_latency})
637
+
638
+ if not scored:
639
+ return None
640
+ scored.sort(key=lambda s: (-s['reputation'], s['price'])) # highest trust first, then cheapest
641
+ return scored[0]
642
+
643
+
644
+ def fetch_verified(relay_urls, event_type):
645
+ """Fetch+verify every event of a type across relays, deduped by event
646
+ id — same merge pattern discover() uses, generalized so subscribe/
647
+ attestation gossip go through the same real signature-checking path."""
648
+ seen = {}
649
+ for relay_url in relay_urls:
650
+ events = fetch_events(relay_url, event_type)
651
+ if events is None:
652
+ continue
653
+ for e in events:
654
+ ok, _ = verify_attestation(e)
655
+ if ok:
656
+ seen[attestation_id(e)] = e
657
+ return list(seen.values())
658
+
659
+
660
+ def download_with_auction(content_hash, relay_urls, out_path=None, k=3, use_lightning=False,
661
+ trust_hops=3, trust_decay=0.5, on_progress=None):
662
+ """The real end-to-end path: resolve every host claiming to have this
663
+ content, challenge-gate them, auction among survivors — weighted by
664
+ reputation built from real transitive trust (your own subscribes, plus
665
+ your subscribes' subscribes, decayed per hop) and real attestations
666
+ gossiped from relays, not just your own direct history — optionally
667
+ pay the winner over a real Lightning HTLC, download, record the
668
+ outcome locally AND publish it so others can benefit transitively too."""
669
+ from poc_reputation import ReputationStore
670
+
671
+ identity = load_or_create_identity()
672
+ candidates = discover_hosts_for(relay_urls, content_hash)
673
+ if not candidates:
674
+ sys.exit(f"no hosts found publishing content matching {content_hash}")
675
+ print(f"found {len(candidates)} candidate host(s) for {content_hash[:16]}...")
676
+
677
+ subscribe_events = fetch_verified(relay_urls, 'subscribe')
678
+ trust_graph = build_trust_graph(subscribe_events, identity.pubkey_hex(),
679
+ max_hops=trust_hops, decay=trust_decay)
680
+ print(f"trust graph: {len(trust_graph)} pubkey(s) reachable within {trust_hops} hop(s) "
681
+ f"of your own subscribes")
682
+
683
+ reputation = ReputationStore(os.path.expanduser('~/.weed_reputation.json'))
684
+ attestation_events = fetch_verified(relay_urls, 'attestation')
685
+ added = 0
686
+ for e in attestation_events:
687
+ ok, _ = reputation.add_attestation(e)
688
+ added += ok
689
+ if attestation_events:
690
+ print(f"pulled {added}/{len(attestation_events)} real attestation(s) from relays "
691
+ f"(others' vouches, weighted by your trust in whoever signed them)")
692
+
693
+ winner = select_host(candidates, k=k, reputation=reputation, trust_in_signers=trust_graph)
694
+ if winner is None:
695
+ sys.exit("no candidate host passed the possession challenge — "
696
+ "refusing to download from an unverified source")
697
+
698
+ c = winner['candidate']
699
+ print(f"selected {c['host']} — price {winner['price']} sat, "
700
+ f"reputation {winner['reputation']:.2f}, {winner['avg_latency_ms']:.1f}ms avg")
701
+
702
+ if winner['price'] > 0:
703
+ if use_lightning:
704
+ import lightning_settle
705
+ result = lightning_settle.settle(
706
+ winner['price'], f"download {content_hash[:16]}... from {c['host']}")
707
+ print(f"paid via real Lightning HTLC: {result['amount_sat']} sat, "
708
+ f"preimage {result['preimage'][:12]}... verified against "
709
+ f"payment_hash {result['payment_hash'][:12]}...")
710
+ else:
711
+ print(f" price is {winner['price']} sat but --lightning not given "
712
+ f"— downloading anyway, unpaid (no enforcement in this demo)")
713
+
714
+ path = download(c['host'], out_path or c['title'], tunnel=_parse_tunnel(c.get('tunnel')),
715
+ content_hash=c['content_hash'], on_progress=on_progress)
716
+
717
+ reputation.record_direct(c['signer_pubkey'], passes=1, fails=0,
718
+ avg_latency_ms=winner['avg_latency_ms'])
719
+ reputation.save()
720
+ print(f"recorded this download in local reputation store "
721
+ f"(~/.weed_reputation.json) for {c['signer_pubkey'][:12]}...")
722
+
723
+ attestation = identity.sign_event('attestation', peer_pubkey=c['signer_pubkey'],
724
+ passes=1, fails=0, avg_latency_ms=winner['avg_latency_ms'], k=k)
725
+ for relay_url in relay_urls:
726
+ post_event(relay_url, attestation)
727
+ print(f"published this outcome as a real attestation — anyone who trusts you "
728
+ f"(directly or transitively) now benefits from it too, without dealing with "
729
+ f"{c['signer_pubkey'][:12]}... themselves first")
730
+ return path
731
+
732
+
733
+ # ── discovery + social signals ──────────────────────────────────────────
734
+
735
+ def post_event(relay_url, event):
736
+ req = urllib.request.Request(
737
+ f'{relay_url}/event', data=json.dumps(event).encode(),
738
+ headers={'Content-Type': 'application/json'}, method='POST')
739
+ try:
740
+ with urllib.request.urlopen(req, timeout=5) as resp:
741
+ return json.loads(resp.read())
742
+ except urllib.error.HTTPError as e:
743
+ return json.loads(e.read())
744
+ except (urllib.error.URLError, ConnectionRefusedError) as e:
745
+ # download_with_auction posts the post-download attestation to every
746
+ # relay in relay_urls, which by default includes 127.0.0.1:9101
747
+ # whether or not anything is actually listening there (same default
748
+ # fetch_events already tolerates) — an unreachable relay here used to
749
+ # take down the whole download with an uncaught traceback *after* the
750
+ # file was already correctly written and reputation already recorded
751
+ return {'ok': False, 'error': str(e)}
752
+
753
+
754
+ def fetch_events(relay_url, event_type=None):
755
+ url = f'{relay_url}/events' + (f'?type={event_type}' if event_type else '')
756
+ try:
757
+ with urllib.request.urlopen(url, timeout=5) as resp:
758
+ return json.loads(resp.read())
759
+ except (urllib.error.URLError, ConnectionRefusedError):
760
+ return None
761
+
762
+
763
+ def publish(identity, relay_url, content_hash, title, host_addr, tunnel=None):
764
+ """tunnel, if given, is 'relay_host:relay_port' for a tunnel_relay.py
765
+ instance this host registered with — additive and backward compatible,
766
+ same as the optional PRICE wire verb: an event without it just means
767
+ 'connect directly to host_addr', same as before this field existed."""
768
+ event = identity.sign_event('publish', content_hash=content_hash, title=title,
769
+ host=host_addr, tunnel=tunnel)
770
+ return post_event(relay_url, event)
771
+
772
+
773
+ def discover(relay_urls):
774
+ seen = {}
775
+ for relay_url in relay_urls:
776
+ events = fetch_events(relay_url, 'publish')
777
+ if events is None:
778
+ print(f" {relay_url}: unreachable, skipped")
779
+ continue
780
+ for e in events:
781
+ ok, _ = verify_attestation(e)
782
+ if ok:
783
+ seen[attestation_id(e)] = e['payload']
784
+ return list(seen.values())