trustnet 1.0.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.
trustnet/__init__.py ADDED
@@ -0,0 +1 @@
1
+ VERSION = "1.0.0"
trustnet/cli.py ADDED
@@ -0,0 +1,293 @@
1
+ """Command-line interface for TrustNet."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ import time
9
+ from pathlib import Path
10
+
11
+ from trustnet.network import DEFAULT_PORT
12
+ from trustnet.core import (
13
+ fingerprint,
14
+ generate_keypair,
15
+ get_config_dir,
16
+ get_public_key_b64,
17
+ sign_directory,
18
+ sign_file,
19
+ verify_directory,
20
+ verify_file,
21
+ )
22
+
23
+
24
+ def _fmt_time(ts: int) -> str:
25
+ return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(ts)) if ts else "unknown"
26
+
27
+
28
+ def cmd_sign(args: argparse.Namespace) -> int:
29
+ path = Path(args.path)
30
+ try:
31
+ if path.is_dir():
32
+ result = sign_directory(path)
33
+ print(f"[TrustNet] Directory signed.")
34
+ print(f" Directory : {result['directory']}")
35
+ print(f" Files : {result['file_count']}")
36
+ print(f" Root hash : {result['root_hash'][:16]}...")
37
+ print(f" Manifest : {result['manifest_file']}")
38
+ print(f" Key : {result['fingerprint']}")
39
+ else:
40
+ result = sign_file(path)
41
+ print(f"[TrustNet] File signed.")
42
+ print(f" File : {result['file']}")
43
+ print(f" SHA-256 : {result['hash'][:16]}...")
44
+ print(f" Signature : {result['sig_file']}")
45
+ print(f" Key : {result['fingerprint']}")
46
+
47
+ if args.json:
48
+ print(json.dumps(result, indent=2))
49
+ return 0
50
+ except Exception as e:
51
+ print(f"[TrustNet] Error: {e}", file=sys.stderr)
52
+ return 1
53
+
54
+
55
+ def cmd_verify(args: argparse.Namespace) -> int:
56
+ path = Path(args.path)
57
+ try:
58
+ if path.is_dir():
59
+ result = verify_directory(path)
60
+ else:
61
+ result = verify_file(path)
62
+
63
+ status = result["status"]
64
+ ok = result["success"]
65
+ symbol = "[OK]" if ok else "[FAIL]"
66
+ print(f"[TrustNet] {symbol} {status}")
67
+ print(f" {result['message']}")
68
+ print(f" Key : {result.get('fingerprint', '-')}")
69
+ print(f" Signed at : {_fmt_time(result.get('timestamp', 0))}")
70
+
71
+ # Network trust info
72
+ n_atts = result.get("network_attestations", 0)
73
+ n_signers = result.get("network_signers", [])
74
+ if result.get("network_online"):
75
+ print(f" Network : {n_atts} attestation(s) from {len(n_signers)} unique signer(s)")
76
+ for s in n_signers:
77
+ print(f" - {s}")
78
+ else:
79
+ print(f" Network : offline (run: trustnet node start)")
80
+
81
+ if not ok and result.get("changed_files"):
82
+ print(f" Changed files ({len(result['changed_files'])}):")
83
+ for f in result["changed_files"][:10]:
84
+ print(f" - {f}")
85
+
86
+ if args.json:
87
+ print(json.dumps(result, indent=2))
88
+
89
+ return 0 if ok else 2
90
+ except Exception as e:
91
+ print(f"[TrustNet] Error: {e}", file=sys.stderr)
92
+ return 1
93
+
94
+
95
+ def cmd_keygen(args: argparse.Namespace) -> int:
96
+ config = get_config_dir()
97
+ private_path = config / "private.key"
98
+ if private_path.exists() and not args.force:
99
+ print("[TrustNet] Key already exists.")
100
+ print(f" Location : {config}")
101
+ print(f" Key : {fingerprint(get_public_key_b64())}")
102
+ print(" Use --force to regenerate (this invalidates all existing signatures).")
103
+ return 0
104
+
105
+ if private_path.exists() and args.force:
106
+ private_path.unlink()
107
+ (config / "public.key").unlink(missing_ok=True)
108
+
109
+ generate_keypair()
110
+ pub_b64 = get_public_key_b64()
111
+ print("[TrustNet] New keypair generated.")
112
+ print(f" Location : {config}")
113
+ print(f" Fingerprint: {fingerprint(pub_b64)}")
114
+ print(f" Public key : {pub_b64[:24]}...")
115
+ return 0
116
+
117
+
118
+ def cmd_pubkey(args: argparse.Namespace) -> int:
119
+ try:
120
+ pub_b64 = get_public_key_b64()
121
+ fp = fingerprint(pub_b64)
122
+ if args.json:
123
+ print(json.dumps({"public_key": pub_b64, "fingerprint": fp}))
124
+ else:
125
+ print(f"Fingerprint : {fp}")
126
+ print(f"Public key : {pub_b64}")
127
+ return 0
128
+ except Exception as e:
129
+ print(f"[TrustNet] Error: {e}", file=sys.stderr)
130
+ return 1
131
+
132
+
133
+ def cmd_node(args: argparse.Namespace) -> int:
134
+ from trustnet.network import node as n, DEFAULT_PORT
135
+
136
+ sub = args.node_cmd
137
+
138
+ if sub == "start":
139
+ if n.is_running():
140
+ print("[TrustNet] Node is already running.")
141
+ info = n.get_local_info()
142
+ if info:
143
+ print(f" Port : {info.get('port', DEFAULT_PORT)}")
144
+ print(f" Attestations : {info.get('attestations', 0)}")
145
+ print(f" Peers : {info.get('peers', 0)}")
146
+ return 0
147
+ port = getattr(args, "port", DEFAULT_PORT)
148
+ print(f"[TrustNet] Starting node on port {port}...")
149
+ try:
150
+ n.start_daemon(port)
151
+ print(f"[TrustNet] Node started.")
152
+ print(f" Port : {port}")
153
+ print(f" Other TrustNet nodes on your network will be discovered automatically.")
154
+ except Exception as e:
155
+ print(f"[TrustNet] Failed to start: {e}", file=sys.stderr)
156
+ return 1
157
+ return 0
158
+
159
+ elif sub == "stop":
160
+ if not n.is_running():
161
+ print("[TrustNet] Node is not running.")
162
+ return 0
163
+ if n.stop_daemon():
164
+ print("[TrustNet] Node stopped.")
165
+ else:
166
+ print("[TrustNet] Could not stop node.", file=sys.stderr)
167
+ return 1
168
+ return 0
169
+
170
+ elif sub == "status":
171
+ if n.is_running():
172
+ info = n.get_local_info()
173
+ print("[TrustNet] Node is RUNNING")
174
+ if info:
175
+ print(f" Port : {info.get('port', DEFAULT_PORT)}")
176
+ print(f" Attestations : {info.get('attestations', 0)}")
177
+ print(f" Peers : {info.get('peers', 0)}")
178
+ else:
179
+ print("[TrustNet] Node is STOPPED")
180
+ print(" Run: trustnet node start")
181
+ return 0
182
+
183
+ elif sub == "peers":
184
+ if not n.is_running():
185
+ print("[TrustNet] Node is not running. Start it first: trustnet node start")
186
+ return 1
187
+ from trustnet.network.ledger import Ledger
188
+ db = Ledger()
189
+ peers = db.get_peers()
190
+ if not peers:
191
+ print("[TrustNet] No peers known yet.")
192
+ print(" Peers on your LAN are discovered automatically.")
193
+ print(" Add a remote peer: trustnet node add <host>")
194
+ else:
195
+ print(f"[TrustNet] {len(peers)} peer(s):")
196
+ for p in peers:
197
+ last = _fmt_time(p.get("last_seen", 0))
198
+ sync = _fmt_time(p.get("last_sync", 0))
199
+ print(f" {p['host']}:{p['port']} seen={last} sync={sync}")
200
+ return 0
201
+
202
+ elif sub == "add":
203
+ host = args.host
204
+ port = getattr(args, "port", DEFAULT_PORT)
205
+ if not n.is_running():
206
+ print("[TrustNet] Node is not running. Start it first: trustnet node start")
207
+ return 1
208
+ from trustnet.network import client
209
+ info = client.get_info(host, port)
210
+ if not info:
211
+ print(f"[TrustNet] Cannot reach {host}:{port}", file=sys.stderr)
212
+ return 1
213
+ from trustnet.network.ledger import Ledger
214
+ Ledger().add_peer(host, port, info.get("node_id", ""))
215
+ print(f"[TrustNet] Peer added: {host}:{port}")
216
+ print(f" Attestations on that node : {info.get('attestations', 0)}")
217
+ print(" Syncing in background...")
218
+ return 0
219
+
220
+ elif sub == "sync":
221
+ if not n.is_running():
222
+ print("[TrustNet] Node is not running.")
223
+ return 1
224
+ from trustnet.network.ledger import Ledger
225
+ from trustnet.network import client
226
+ from trustnet.network.protocol import verify_attestation
227
+ db = Ledger()
228
+ peers = db.get_peers()
229
+ if not peers:
230
+ print("[TrustNet] No peers to sync with.")
231
+ return 0
232
+ total = 0
233
+ for p in peers:
234
+ atts = client.sync_since(p["host"], p["port"], p.get("last_sync", 0))
235
+ new = sum(1 for a in atts if verify_attestation(a) and db.add(a))
236
+ total += new
237
+ db.update_sync(p["host"], p["port"])
238
+ print(f" {p['host']}:{p['port']} -> {new} new attestation(s)")
239
+ print(f"[TrustNet] Sync complete. {total} new attestation(s) total.")
240
+ return 0
241
+
242
+ print(f"[TrustNet] Unknown node command: {sub}", file=sys.stderr)
243
+ return 1
244
+
245
+
246
+ def build_parser() -> argparse.ArgumentParser:
247
+ parser = argparse.ArgumentParser(
248
+ prog="trustnet",
249
+ description="TrustNet — cryptographic file & package signing",
250
+ )
251
+ parser.add_argument("--json", action="store_true", help="Output JSON")
252
+ sub = parser.add_subparsers(dest="command", required=True)
253
+
254
+ p_sign = sub.add_parser("sign", help="Sign a file or directory")
255
+ p_sign.add_argument("path", help="File or directory to sign")
256
+ p_sign.set_defaults(func=cmd_sign)
257
+
258
+ p_verify = sub.add_parser("verify", help="Verify a file or directory")
259
+ p_verify.add_argument("path", help="File or directory to verify")
260
+ p_verify.set_defaults(func=cmd_verify)
261
+
262
+ p_keygen = sub.add_parser("keygen", help="Generate a new keypair")
263
+ p_keygen.add_argument("--force", action="store_true", help="Overwrite existing key")
264
+ p_keygen.set_defaults(func=cmd_keygen)
265
+
266
+ p_pub = sub.add_parser("pubkey", help="Show your public key and fingerprint")
267
+ p_pub.set_defaults(func=cmd_pubkey)
268
+
269
+ # node subcommand with sub-subcommands
270
+ p_node = sub.add_parser("node", help="Manage the TrustNet P2P node")
271
+ node_sub = p_node.add_subparsers(dest="node_cmd", required=True)
272
+
273
+ ns = node_sub.add_parser("start", help="Start the P2P node daemon")
274
+ ns.add_argument("--port", type=int, default=DEFAULT_PORT)
275
+
276
+ node_sub.add_parser("stop", help="Stop the node daemon")
277
+ node_sub.add_parser("status", help="Show node status")
278
+ node_sub.add_parser("peers", help="List known peers")
279
+ node_sub.add_parser("sync", help="Force sync with all peers")
280
+
281
+ na = node_sub.add_parser("add", help="Manually add a peer by host")
282
+ na.add_argument("host", help="Peer hostname or IP address")
283
+ na.add_argument("--port", type=int, default=DEFAULT_PORT)
284
+
285
+ p_node.set_defaults(func=cmd_node)
286
+
287
+ return parser
288
+
289
+
290
+ def main(argv: list[str] | None = None) -> int:
291
+ parser = build_parser()
292
+ args = parser.parse_args(argv)
293
+ return args.func(args)
trustnet/core.py ADDED
@@ -0,0 +1,376 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import sys
8
+ import threading
9
+ import time
10
+ from pathlib import Path
11
+
12
+ from cryptography.exceptions import InvalidSignature
13
+ from cryptography.hazmat.primitives import serialization
14
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
15
+ Ed25519PrivateKey,
16
+ Ed25519PublicKey,
17
+ )
18
+
19
+
20
+ def _try_publish(sign_result: dict) -> None:
21
+ """Fire-and-forget: publish attestation to local node if it's running."""
22
+ def _bg():
23
+ try:
24
+ from trustnet.network.protocol import build_attestation
25
+ from trustnet.network.node import publish_attestation, is_running
26
+ if not is_running():
27
+ return
28
+ node_id = get_public_key_b64()
29
+ att = build_attestation(sign_result, node_id)
30
+ publish_attestation(att)
31
+ except Exception:
32
+ pass
33
+ threading.Thread(target=_bg, daemon=True).start()
34
+
35
+ VERSION = "1.0.0"
36
+ SIG_EXT = ".trustsig"
37
+
38
+
39
+ # ── Config directory (per platform) ──────────────────────────────────────────
40
+
41
+ def get_config_dir() -> Path:
42
+ if sys.platform == "win32":
43
+ base = Path(os.environ.get("APPDATA", Path.home()))
44
+ return base / "TrustNet"
45
+ elif sys.platform == "darwin":
46
+ return Path.home() / "Library" / "Application Support" / "TrustNet"
47
+ else:
48
+ xdg = os.environ.get("XDG_CONFIG_HOME", "")
49
+ base = Path(xdg) if xdg else Path.home() / ".config"
50
+ return base / "trustnet"
51
+
52
+
53
+ def _ensure_config() -> Path:
54
+ d = get_config_dir()
55
+ d.mkdir(parents=True, exist_ok=True)
56
+ return d
57
+
58
+
59
+ # ── Key management ────────────────────────────────────────────────────────────
60
+
61
+ def _key_paths() -> tuple[Path, Path]:
62
+ config = _ensure_config()
63
+ return config / "private.key", config / "public.key"
64
+
65
+
66
+ def generate_keypair() -> None:
67
+ private_path, public_path = _key_paths()
68
+ if private_path.exists():
69
+ return
70
+
71
+ key = Ed25519PrivateKey.generate()
72
+
73
+ private_pem = key.private_bytes(
74
+ encoding=serialization.Encoding.PEM,
75
+ format=serialization.PrivateFormat.PKCS8,
76
+ encryption_algorithm=serialization.NoEncryption(),
77
+ )
78
+ public_raw = key.public_key().public_bytes(
79
+ encoding=serialization.Encoding.Raw,
80
+ format=serialization.PublicFormat.Raw,
81
+ )
82
+
83
+ private_path.write_bytes(private_pem)
84
+ public_path.write_text(base64.b64encode(public_raw).decode())
85
+
86
+ if sys.platform != "win32":
87
+ os.chmod(private_path, 0o600)
88
+
89
+
90
+ def _load_private_key() -> Ed25519PrivateKey:
91
+ private_path, _ = _key_paths()
92
+ if not private_path.exists():
93
+ generate_keypair()
94
+ return serialization.load_pem_private_key(private_path.read_bytes(), password=None)
95
+
96
+
97
+ def get_public_key_b64() -> str:
98
+ _, public_path = _key_paths()
99
+ if not public_path.exists():
100
+ generate_keypair()
101
+ return public_path.read_text().strip()
102
+
103
+
104
+ def fingerprint(pub_b64: str) -> str:
105
+ raw = base64.b64decode(pub_b64)
106
+ h = hashlib.sha256(raw).hexdigest().upper()
107
+ return ":".join(h[i : i + 4] for i in range(0, 24, 4))
108
+
109
+
110
+ # ── Hashing ───────────────────────────────────────────────────────────────────
111
+
112
+ def hash_file(path: Path) -> str:
113
+ h = hashlib.sha256()
114
+ with open(path, "rb") as f:
115
+ for chunk in iter(lambda: f.read(65536), b""):
116
+ h.update(chunk)
117
+ return h.hexdigest()
118
+
119
+
120
+ def hash_directory(path: Path) -> tuple[str, dict]:
121
+ """Return (root_hash, {relative_path: hash}) for all files under path."""
122
+ path = Path(path)
123
+ file_hashes: dict[str, str] = {}
124
+
125
+ for fp in sorted(path.rglob("*")):
126
+ if fp.is_file() and fp.suffix != SIG_EXT and fp.name != "trustnet.manifest.json":
127
+ rel = fp.relative_to(path).as_posix()
128
+ file_hashes[rel] = hash_file(fp)
129
+
130
+ combined = "\n".join(f"{v} {k}" for k, v in sorted(file_hashes.items()))
131
+ root_hash = hashlib.sha256(combined.encode()).hexdigest()
132
+ return root_hash, file_hashes
133
+
134
+
135
+ # ── Signing ───────────────────────────────────────────────────────────────────
136
+
137
+ def _make_message(kind: str, name: str, content_hash: str) -> bytes:
138
+ return f"trustnet:v1:{kind}:{name}:{content_hash}".encode()
139
+
140
+
141
+ def sign_file(file_path: Path) -> dict:
142
+ file_path = Path(file_path).resolve()
143
+ if not file_path.exists():
144
+ raise FileNotFoundError(f"File not found: {file_path}")
145
+
146
+ private_key = _load_private_key()
147
+ pub_b64 = get_public_key_b64()
148
+ file_hash = hash_file(file_path)
149
+ message = _make_message("file", file_path.name, file_hash)
150
+ signature = base64.b64encode(private_key.sign(message)).decode()
151
+
152
+ sig_data = {
153
+ "trustnet_version": VERSION,
154
+ "kind": "file",
155
+ "file": file_path.name,
156
+ "hash": file_hash,
157
+ "hash_algorithm": "sha256",
158
+ "signature": signature,
159
+ "public_key": pub_b64,
160
+ "timestamp": int(time.time()),
161
+ }
162
+
163
+ sig_path = file_path.parent / (file_path.name + SIG_EXT)
164
+ sig_path.write_text(json.dumps(sig_data, indent=2))
165
+
166
+ result = {
167
+ "success": True,
168
+ "file": str(file_path),
169
+ "hash": file_hash,
170
+ "sig_file": str(sig_path),
171
+ "fingerprint": fingerprint(pub_b64),
172
+ "timestamp": sig_data["timestamp"],
173
+ }
174
+ _try_publish(result)
175
+ return result
176
+
177
+
178
+ def sign_directory(dir_path: Path) -> dict:
179
+ dir_path = Path(dir_path).resolve()
180
+ if not dir_path.is_dir():
181
+ raise NotADirectoryError(f"Not a directory: {dir_path}")
182
+
183
+ private_key = _load_private_key()
184
+ pub_b64 = get_public_key_b64()
185
+ root_hash, file_hashes = hash_directory(dir_path)
186
+ message = _make_message("dir", dir_path.name, root_hash)
187
+ signature = base64.b64encode(private_key.sign(message)).decode()
188
+
189
+ manifest = {
190
+ "trustnet_version": VERSION,
191
+ "kind": "directory",
192
+ "directory": dir_path.name,
193
+ "root_hash": root_hash,
194
+ "hash_algorithm": "sha256",
195
+ "file_count": len(file_hashes),
196
+ "files": file_hashes,
197
+ "signature": signature,
198
+ "public_key": pub_b64,
199
+ "timestamp": int(time.time()),
200
+ }
201
+
202
+ manifest_path = dir_path / "trustnet.manifest.json"
203
+ manifest_path.write_text(json.dumps(manifest, indent=2))
204
+
205
+ result = {
206
+ "success": True,
207
+ "directory": str(dir_path),
208
+ "root_hash": root_hash,
209
+ "file_count": len(file_hashes),
210
+ "manifest_file": str(manifest_path),
211
+ "fingerprint": fingerprint(pub_b64),
212
+ "timestamp": manifest["timestamp"],
213
+ }
214
+ _try_publish(result)
215
+ return result
216
+
217
+
218
+ # ── Verification ──────────────────────────────────────────────────────────────
219
+
220
+ def _verify_signature(pub_b64: str, sig_b64: str, message: bytes) -> bool:
221
+ try:
222
+ pub_raw = base64.b64decode(pub_b64)
223
+ pub_key = Ed25519PublicKey.from_public_bytes(pub_raw)
224
+ pub_key.verify(base64.b64decode(sig_b64), message)
225
+ return True
226
+ except (InvalidSignature, Exception):
227
+ return False
228
+
229
+
230
+ def verify_file(file_path: Path) -> dict:
231
+ file_path = Path(file_path).resolve()
232
+
233
+ if str(file_path).endswith(SIG_EXT):
234
+ sig_path = file_path
235
+ original_path = Path(str(file_path)[: -len(SIG_EXT)])
236
+ else:
237
+ sig_path = file_path.parent / (file_path.name + SIG_EXT)
238
+ original_path = file_path
239
+
240
+ if not sig_path.exists():
241
+ return {"success": False, "status": "NO_SIGNATURE",
242
+ "message": "No .trustsig file found alongside this file.",
243
+ "file": str(original_path)}
244
+
245
+ if not original_path.exists():
246
+ return {"success": False, "status": "FILE_MISSING",
247
+ "message": "Original file is missing.",
248
+ "file": str(original_path)}
249
+
250
+ try:
251
+ sig_data = json.loads(sig_path.read_text())
252
+ except json.JSONDecodeError:
253
+ return {"success": False, "status": "CORRUPT_SIGNATURE",
254
+ "message": "Signature file is corrupted or unreadable.",
255
+ "file": str(original_path)}
256
+
257
+ current_hash = hash_file(original_path)
258
+ stored_hash = sig_data.get("hash", "")
259
+ hash_ok = current_hash == stored_hash
260
+
261
+ pub_b64 = sig_data.get("public_key", "")
262
+ sig_b64 = sig_data.get("signature", "")
263
+ message = _make_message("file", sig_data.get("file", original_path.name), stored_hash)
264
+ sig_ok = _verify_signature(pub_b64, sig_b64, message)
265
+
266
+ own_pub = get_public_key_b64()
267
+ is_mine = pub_b64 == own_pub
268
+
269
+ if hash_ok and sig_ok:
270
+ status = "VERIFIED"
271
+ success = True
272
+ message_str = "File is authentic and untampered."
273
+ elif not hash_ok:
274
+ status = "TAMPERED"
275
+ success = False
276
+ message_str = "File content has changed since it was signed."
277
+ else:
278
+ status = "INVALID_SIGNATURE"
279
+ success = False
280
+ message_str = "Signature is invalid or was not made with the correct key."
281
+
282
+ return {
283
+ "success": success,
284
+ "status": status,
285
+ "message": message_str,
286
+ "file": str(original_path),
287
+ "hash_match": hash_ok,
288
+ "signature_valid": sig_ok,
289
+ "fingerprint": fingerprint(pub_b64) if pub_b64 else "unknown",
290
+ "is_own_key": is_mine,
291
+ "timestamp": sig_data.get("timestamp", 0),
292
+ **_network_trust(stored_hash),
293
+ }
294
+
295
+
296
+ def verify_directory(dir_path: Path) -> dict:
297
+ dir_path = Path(dir_path).resolve()
298
+ manifest_path = dir_path / "trustnet.manifest.json"
299
+
300
+ if not manifest_path.exists():
301
+ return {"success": False, "status": "NO_MANIFEST",
302
+ "message": "No trustnet.manifest.json found in this directory.",
303
+ "directory": str(dir_path)}
304
+
305
+ try:
306
+ manifest = json.loads(manifest_path.read_text())
307
+ except json.JSONDecodeError:
308
+ return {"success": False, "status": "CORRUPT_MANIFEST",
309
+ "message": "Manifest file is corrupted.",
310
+ "directory": str(dir_path)}
311
+
312
+ current_root_hash, current_files = hash_directory(dir_path)
313
+ stored_root_hash = manifest.get("root_hash", "")
314
+ hash_ok = current_root_hash == stored_root_hash
315
+
316
+ pub_b64 = manifest.get("public_key", "")
317
+ sig_b64 = manifest.get("signature", "")
318
+ message = _make_message("dir", manifest.get("directory", dir_path.name), stored_root_hash)
319
+ sig_ok = _verify_signature(pub_b64, sig_b64, message)
320
+
321
+ changed_files = []
322
+ stored_files = manifest.get("files", {})
323
+ for rel, stored_hash in stored_files.items():
324
+ current = current_files.get(rel)
325
+ if current != stored_hash:
326
+ changed_files.append(rel)
327
+ new_files = [f for f in current_files if f not in stored_files]
328
+
329
+ if hash_ok and sig_ok:
330
+ status = "VERIFIED"
331
+ success = True
332
+ message_str = f"All {len(stored_files)} files verified. Directory is untampered."
333
+ elif not hash_ok:
334
+ status = "TAMPERED"
335
+ success = False
336
+ message_str = f"{len(changed_files)} file(s) changed, {len(new_files)} new file(s) added."
337
+ else:
338
+ status = "INVALID_SIGNATURE"
339
+ success = False
340
+ message_str = "Directory signature is invalid."
341
+
342
+ return {
343
+ "success": success,
344
+ "status": status,
345
+ "message": message_str,
346
+ "directory": str(dir_path),
347
+ "hash_match": hash_ok,
348
+ "signature_valid": sig_ok,
349
+ "fingerprint": fingerprint(pub_b64) if pub_b64 else "unknown",
350
+ "file_count": len(stored_files),
351
+ "changed_files": changed_files,
352
+ "new_files": new_files,
353
+ "timestamp": manifest.get("timestamp", 0),
354
+ **_network_trust(current_root_hash),
355
+ }
356
+
357
+
358
+ def _network_trust(file_hash: str) -> dict:
359
+ """Query local node for network attestations of this hash."""
360
+ try:
361
+ from trustnet.network.node import query_network
362
+ from trustnet.network.protocol import verify_attestation, fingerprint as fp
363
+ atts = query_network(file_hash)
364
+ valid = [a for a in atts if verify_attestation(a)]
365
+ signers = list({fp(a["public_key"]) for a in valid})
366
+ return {
367
+ "network_attestations": len(valid),
368
+ "network_signers": signers,
369
+ "network_online": True,
370
+ }
371
+ except Exception:
372
+ return {
373
+ "network_attestations": 0,
374
+ "network_signers": [],
375
+ "network_online": False,
376
+ }