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 +1 -0
- trustnet/cli.py +293 -0
- trustnet/core.py +376 -0
- trustnet/gui.py +198 -0
- trustnet/icon.py +113 -0
- trustnet/network/__init__.py +2 -0
- trustnet/network/client.py +73 -0
- trustnet/network/discovery.py +96 -0
- trustnet/network/ledger.py +135 -0
- trustnet/network/node.py +360 -0
- trustnet/network/protocol.py +76 -0
- trustnet-1.0.0.dist-info/METADATA +154 -0
- trustnet-1.0.0.dist-info/RECORD +17 -0
- trustnet-1.0.0.dist-info/WHEEL +5 -0
- trustnet-1.0.0.dist-info/entry_points.txt +2 -0
- trustnet-1.0.0.dist-info/licenses/LICENSE.txt +20 -0
- trustnet-1.0.0.dist-info/top_level.txt +1 -0
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
|
+
}
|