dethron 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.
- dethron/__init__.py +28 -0
- dethron/adapter.py +75 -0
- dethron/assembly.py +86 -0
- dethron/audit.py +107 -0
- dethron/cli.py +170 -0
- dethron/config.py +39 -0
- dethron/custody.py +78 -0
- dethron/database.py +56 -0
- dethron/endpoint.py +171 -0
- dethron/erasure.py +42 -0
- dethron/handlers.py +133 -0
- dethron/lxmf_stamp.py +94 -0
- dethron/mailbox.py +81 -0
- dethron/node.py +153 -0
- dethron/parts.py +59 -0
- dethron/protocol.py +97 -0
- dethron/receiver.py +55 -0
- dethron/wire.py +44 -0
- dethron-0.1.0.dist-info/METADATA +162 -0
- dethron-0.1.0.dist-info/RECORD +25 -0
- dethron-0.1.0.dist-info/WHEEL +5 -0
- dethron-0.1.0.dist-info/entry_points.txt +2 -0
- dethron-0.1.0.dist-info/licenses/LICENSE +202 -0
- dethron-0.1.0.dist-info/licenses/NOTICE +9 -0
- dethron-0.1.0.dist-info/top_level.txt +1 -0
dethron/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Verifiable message delivery over Reticulum and LXMF.
|
|
2
|
+
|
|
3
|
+
Guaranteed delivery is impossible over intermittent contact: if the recipient never
|
|
4
|
+
appears, nothing reaches them. What is possible is knowing, with cryptographic proof and
|
|
5
|
+
without trusting the relay, which state a message is in — it entered the network, it is
|
|
6
|
+
pending at a named relay, or the recipient signed for it.
|
|
7
|
+
|
|
8
|
+
from dethron import Node
|
|
9
|
+
|
|
10
|
+
with Node('./alice', peers=['relay.example:45810']) as node:
|
|
11
|
+
handoff = node.send(payload, to=recipient, via=relay_propagation)
|
|
12
|
+
proof = node.custody_of(handoff['transient_id'], relay=relay_contact)
|
|
13
|
+
|
|
14
|
+
`proof` is the relay's signature over the message it is holding, for that recipient. It
|
|
15
|
+
proves entry, not delivery: only the recipient's receipt proves exit, and a relay that
|
|
16
|
+
attests and then discards is named by the absence of that receipt.
|
|
17
|
+
"""
|
|
18
|
+
from . import custody
|
|
19
|
+
from .audit import verify_directory
|
|
20
|
+
from .endpoint import Endpoint
|
|
21
|
+
from .node import Node, parse_contact, parse_relay
|
|
22
|
+
from .protocol import data_envelope, decode, encode, receipt_envelope
|
|
23
|
+
from .wire import authenticate, declared_expiry
|
|
24
|
+
|
|
25
|
+
__version__ = '0.1.0'
|
|
26
|
+
__all__ = ['Endpoint', 'Node', 'authenticate', 'custody', 'data_envelope', 'decode',
|
|
27
|
+
'declared_expiry', 'encode', 'parse_contact', 'parse_relay', 'receipt_envelope',
|
|
28
|
+
'verify_directory']
|
dethron/adapter.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""LXMF direct delivery adapter. pump() is explicit; no simulated network fallback."""
|
|
2
|
+
import threading
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
import LXMF
|
|
6
|
+
import RNS
|
|
7
|
+
|
|
8
|
+
from .protocol import APPLICATION, encode
|
|
9
|
+
from .wire import authenticate
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Adapter:
|
|
13
|
+
def __init__(self, router, source, mailbox, emit):
|
|
14
|
+
self.router, self.source, self.mailbox, self.emit = router, source, mailbox, emit
|
|
15
|
+
self.active = set()
|
|
16
|
+
self.lock = threading.RLock()
|
|
17
|
+
router.register_delivery_callback(self.receive)
|
|
18
|
+
|
|
19
|
+
def receive(self, message):
|
|
20
|
+
try:
|
|
21
|
+
identity = RNS.Identity.recall(message.source_hash)
|
|
22
|
+
if identity is None:
|
|
23
|
+
raise ValueError("unknown source identity")
|
|
24
|
+
obj = authenticate(message.packed, identity.get_public_key(), self.source.hash.hex(), time.time())
|
|
25
|
+
fresh = self.mailbox.accept_verified(obj, message.packed, time.time())
|
|
26
|
+
self.emit("confirmed" if obj["kind"] == "receipt" else "received",
|
|
27
|
+
id=obj["id"], fresh=fresh, digest=obj["digest"])
|
|
28
|
+
except Exception as exc:
|
|
29
|
+
self.emit("rejected", error=repr(exc))
|
|
30
|
+
|
|
31
|
+
def transmit(self, destination, body, callback=None, failed=None):
|
|
32
|
+
dest_hash = bytes.fromhex(destination)
|
|
33
|
+
identity = RNS.Identity.recall(dest_hash)
|
|
34
|
+
if identity is None:
|
|
35
|
+
RNS.Transport.request_path(dest_hash)
|
|
36
|
+
return False
|
|
37
|
+
target = RNS.Destination(identity, RNS.Destination.OUT, RNS.Destination.SINGLE, "lxmf", "delivery")
|
|
38
|
+
message = LXMF.LXMessage(target, self.source, body, APPLICATION, desired_method=LXMF.LXMessage.DIRECT)
|
|
39
|
+
if callback:
|
|
40
|
+
message.register_delivery_callback(callback)
|
|
41
|
+
if failed:
|
|
42
|
+
message.register_failed_callback(failed)
|
|
43
|
+
self.router.handle_outbound(message)
|
|
44
|
+
return True
|
|
45
|
+
|
|
46
|
+
def _complete(self, key, message_id, succeeded):
|
|
47
|
+
with self.lock:
|
|
48
|
+
if succeeded:
|
|
49
|
+
self.mailbox.handoff(key)
|
|
50
|
+
self.active.discard(key)
|
|
51
|
+
self.emit("handoff" if succeeded else "attempt_failed", id=message_id)
|
|
52
|
+
|
|
53
|
+
def pump(self):
|
|
54
|
+
with self.lock:
|
|
55
|
+
for item in self.mailbox.pending(time.time()):
|
|
56
|
+
key, obj = item["key"], item["envelope"]
|
|
57
|
+
if key in self.active:
|
|
58
|
+
continue
|
|
59
|
+
dest_hash = bytes.fromhex(obj["destination"])
|
|
60
|
+
# Native LXMF can reuse a backchannel even without a cached route.
|
|
61
|
+
if RNS.Identity.recall(dest_hash) is None:
|
|
62
|
+
RNS.Transport.request_path(dest_hash)
|
|
63
|
+
self.emit("no_path", id=obj["id"])
|
|
64
|
+
continue
|
|
65
|
+
self.mailbox.begin_attempt(key, time.time())
|
|
66
|
+
self.active.add(key)
|
|
67
|
+
success = lambda m, k=key, i=obj["id"]: self._complete(k, i, True)
|
|
68
|
+
failure = lambda m, k=key, i=obj["id"]: self._complete(k, i, False)
|
|
69
|
+
try:
|
|
70
|
+
sent = self.transmit(obj["destination"], encode(obj), success, failure)
|
|
71
|
+
if not sent:
|
|
72
|
+
self.active.discard(key)
|
|
73
|
+
except Exception:
|
|
74
|
+
self.active.discard(key)
|
|
75
|
+
raise
|
dethron/assembly.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Transactional partial assembly with bounded retained evidence."""
|
|
2
|
+
import base64
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import sqlite3
|
|
7
|
+
|
|
8
|
+
from .parts import validate
|
|
9
|
+
from .erasure import reconstruct
|
|
10
|
+
from .protocol import decode, encode
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Assembly:
|
|
14
|
+
def __init__(self, path, identity, max_bytes=8*1024*1024):
|
|
15
|
+
self.path, self.identity, self.max_bytes = path, identity, max_bytes
|
|
16
|
+
with self.transaction() as db:
|
|
17
|
+
db.execute("CREATE TABLE IF NOT EXISTS owner(identity TEXT,version INTEGER)")
|
|
18
|
+
owner = db.execute("SELECT * FROM owner").fetchone()
|
|
19
|
+
if owner and owner != (identity, 1):
|
|
20
|
+
raise ValueError("assembly identity/schema mismatch")
|
|
21
|
+
if not owner:
|
|
22
|
+
db.execute("INSERT INTO owner VALUES (?,1)", (identity,))
|
|
23
|
+
db.execute("CREATE TABLE IF NOT EXISTS objects(key TEXT PRIMARY KEY,manifest BLOB,expires INTEGER,result BLOB)")
|
|
24
|
+
db.execute("CREATE TABLE IF NOT EXISTS parts(key TEXT,idx INTEGER,data BLOB,wire BLOB,PRIMARY KEY(key,idx))")
|
|
25
|
+
|
|
26
|
+
@contextmanager
|
|
27
|
+
def transaction(self):
|
|
28
|
+
db = sqlite3.connect(self.path, timeout=5, isolation_level=None)
|
|
29
|
+
try:
|
|
30
|
+
db.execute("PRAGMA journal_mode=DELETE")
|
|
31
|
+
db.execute("PRAGMA synchronous=FULL")
|
|
32
|
+
db.execute("BEGIN IMMEDIATE")
|
|
33
|
+
yield db
|
|
34
|
+
db.commit()
|
|
35
|
+
except BaseException:
|
|
36
|
+
db.rollback()
|
|
37
|
+
raise
|
|
38
|
+
finally:
|
|
39
|
+
db.close()
|
|
40
|
+
|
|
41
|
+
def accept_verified(self, envelope, wire, now):
|
|
42
|
+
obj = decode(encode(envelope), now)
|
|
43
|
+
if obj["kind"] != "data" or obj["destination"] != self.identity:
|
|
44
|
+
raise ValueError("not local authenticated data")
|
|
45
|
+
manifest, index, content = validate(base64.b64decode(obj["payload"], validate=True))
|
|
46
|
+
key = "/".join([obj["source"], obj["destination"], manifest["id"]])
|
|
47
|
+
encoded = encode(manifest)
|
|
48
|
+
with self.transaction() as db:
|
|
49
|
+
existing = db.execute("SELECT manifest,expires FROM objects WHERE key=?", (key,)).fetchone()
|
|
50
|
+
if existing and existing != (encoded, obj["expires"]):
|
|
51
|
+
raise ValueError("incompatible manifest or lifetime")
|
|
52
|
+
if not existing:
|
|
53
|
+
if db.execute("SELECT count(*) FROM objects").fetchone()[0] >= 16:
|
|
54
|
+
raise ValueError("object count limit")
|
|
55
|
+
db.execute("INSERT INTO objects VALUES (?,?,?,NULL)", (key, encoded, obj["expires"]))
|
|
56
|
+
previous = db.execute("SELECT data FROM parts WHERE key=? AND idx=?", (key, index)).fetchone()
|
|
57
|
+
fresh = previous is None
|
|
58
|
+
if previous and previous[0] != content:
|
|
59
|
+
raise ValueError("conflicting part")
|
|
60
|
+
if fresh:
|
|
61
|
+
db.execute("INSERT INTO parts VALUES (?,?,?,?)", (key, index, content, wire))
|
|
62
|
+
chunks = dict(db.execute("SELECT idx,data FROM parts WHERE key=? ORDER BY idx", (key,)))
|
|
63
|
+
joined = reconstruct(manifest, chunks)
|
|
64
|
+
complete = joined is not None
|
|
65
|
+
if complete:
|
|
66
|
+
db.execute("UPDATE objects SET result=? WHERE key=?", (joined, key))
|
|
67
|
+
used = db.execute("SELECT COALESCE(SUM(length(manifest)+COALESCE(length(result),0)),0) FROM objects").fetchone()[0]
|
|
68
|
+
used += db.execute("SELECT COALESCE(SUM(length(data)+length(wire)),0) FROM parts").fetchone()[0]
|
|
69
|
+
if used > self.max_bytes:
|
|
70
|
+
raise ValueError("assembly capacity exceeded")
|
|
71
|
+
return {"key": key, "id": manifest["id"], "count": len(chunks), "complete": complete,
|
|
72
|
+
"fresh": fresh, "digest": manifest["digest"], "size": manifest["size"]}
|
|
73
|
+
|
|
74
|
+
def content(self, key):
|
|
75
|
+
with self.transaction() as db:
|
|
76
|
+
row = db.execute("SELECT result FROM objects WHERE key=?", (key,)).fetchone()
|
|
77
|
+
return row[0] if row else None
|
|
78
|
+
|
|
79
|
+
def snapshot(self):
|
|
80
|
+
with self.transaction() as db:
|
|
81
|
+
result = []
|
|
82
|
+
for key, raw, content in db.execute("SELECT key,manifest,result FROM objects"):
|
|
83
|
+
m = json.loads(raw)
|
|
84
|
+
count = db.execute("SELECT count(*) FROM parts WHERE key=?", (key,)).fetchone()[0]
|
|
85
|
+
result.append({"id": m["id"], "count": count, "complete": content is not None})
|
|
86
|
+
return result
|
dethron/audit.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Recompute every proof a node directory holds, trusting nothing that produced it.
|
|
2
|
+
|
|
3
|
+
This is deliberately separate from the code that obtains proofs. An auditor that shares
|
|
4
|
+
its assumptions with the thing it audits is a formality. Everything here reads files and
|
|
5
|
+
checks signatures; it never asks a node what happened.
|
|
6
|
+
|
|
7
|
+
Each packet is checked inside the validity window it declared for itself. A proof that was
|
|
8
|
+
sound when it was made does not stop being sound when it expires, and a verifier that said
|
|
9
|
+
otherwise would quietly discard old evidence.
|
|
10
|
+
"""
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
import time
|
|
15
|
+
|
|
16
|
+
import RNS
|
|
17
|
+
|
|
18
|
+
from . import custody as attestation
|
|
19
|
+
from .endpoint import parse_contact
|
|
20
|
+
from .wire import authenticate, declared_expiry
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def verify_directory(home):
|
|
24
|
+
"""Return a report: every check, whether it passed, and what it proves.
|
|
25
|
+
|
|
26
|
+
The report is data, not a verdict to be taken on faith: each entry names the file it
|
|
27
|
+
came from so a reader can repeat the check by hand.
|
|
28
|
+
"""
|
|
29
|
+
home = Path(home)
|
|
30
|
+
if not (home/'identity').exists():
|
|
31
|
+
return {'home': str(home), 'verdict': 'nothing to verify',
|
|
32
|
+
'hint': 'no identity here; run send or fetch in this directory first',
|
|
33
|
+
'checks': []}
|
|
34
|
+
identity = RNS.Identity.from_file(str(home/'identity'))
|
|
35
|
+
mine = RNS.Destination.hash(identity, 'lxmf', 'delivery').hex()
|
|
36
|
+
checks = _object(home)+_custody(home, mine)+_receipts(home, mine)
|
|
37
|
+
if not checks:
|
|
38
|
+
return {'home': str(home), 'verdict': 'nothing to verify', 'checks': [],
|
|
39
|
+
'hint': 'no proofs here yet; run send or fetch in this directory first'}
|
|
40
|
+
passed = all(check['passed'] for check in checks)
|
|
41
|
+
return {'home': str(home), 'verdict': 'pass' if passed else 'fail', 'checks': checks}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _object(home):
|
|
45
|
+
"""What was reconstructed must be what the receipt was issued over."""
|
|
46
|
+
output, completion = home/'output.bin', home/'completion.json'
|
|
47
|
+
if not (output.exists() and completion.exists()):
|
|
48
|
+
return []
|
|
49
|
+
envelope = json.loads(completion.read_text(encoding='utf-8'))
|
|
50
|
+
digest = hashlib.sha256(output.read_bytes()).hexdigest()
|
|
51
|
+
return [{'check': 'the reconstructed object matches the receipt issued for it',
|
|
52
|
+
'passed': digest == envelope.get('digest'), 'file': output.name,
|
|
53
|
+
'sha256': digest, 'bytes': output.stat().st_size}]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _custody(home, mine):
|
|
57
|
+
"""Proof of entry: signed by the relay that was asked, for this message, to us."""
|
|
58
|
+
checks = []
|
|
59
|
+
for packet in sorted((home/'custody').glob('*.lxmf')) if (home/'custody').exists() else []:
|
|
60
|
+
transient_id = packet.name.split('.')[0]
|
|
61
|
+
refused = packet.name.endswith('.refused.lxmf')
|
|
62
|
+
label = ('refusal' if refused else 'custody')+f' {transient_id[:8]}'
|
|
63
|
+
sidecar = packet.with_suffix('.relay')
|
|
64
|
+
if not sidecar.exists():
|
|
65
|
+
checks.append({'check': f'{label} names the relay that signed it', 'passed': False,
|
|
66
|
+
'file': packet.name,
|
|
67
|
+
'error': 'no relay contact was recorded beside the packet'})
|
|
68
|
+
continue
|
|
69
|
+
raw = packet.read_bytes()
|
|
70
|
+
try:
|
|
71
|
+
_, key = parse_contact(sidecar.read_text(encoding='utf-8'))
|
|
72
|
+
check = attestation.verify_refusal if refused else attestation.verify
|
|
73
|
+
obj = check(raw, bytes.fromhex(key), mine, transient_id, declared_expiry(raw)-1)
|
|
74
|
+
checks.append({'check': f'{label} is signed by the relay that was asked',
|
|
75
|
+
'passed': True, 'file': packet.name,
|
|
76
|
+
'proves': 'no entry' if refused else 'entry',
|
|
77
|
+
**{k: obj[k] for k in ('stored_size', 'reason') if k in obj}})
|
|
78
|
+
except Exception as exc:
|
|
79
|
+
checks.append({'check': label, 'passed': False, 'file': packet.name,
|
|
80
|
+
'error': repr(exc)})
|
|
81
|
+
return checks
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _receipts(home, mine):
|
|
85
|
+
"""Proof of exit: signed by a recipient this node actually sent to, over these bytes."""
|
|
86
|
+
path = home/'recipients.json'
|
|
87
|
+
recipients = json.loads(path.read_text(encoding='utf-8')) if path.exists() else {}
|
|
88
|
+
checks = []
|
|
89
|
+
for packet in sorted((home/'receipts').glob('*.lxmf')) if (home/'receipts').exists() else []:
|
|
90
|
+
raw, found = packet.read_bytes(), None
|
|
91
|
+
for destination, key in recipients.items():
|
|
92
|
+
try:
|
|
93
|
+
found = (destination, authenticate(raw, bytes.fromhex(key), mine,
|
|
94
|
+
declared_expiry(raw)-1))
|
|
95
|
+
break
|
|
96
|
+
except Exception:
|
|
97
|
+
continue
|
|
98
|
+
if found is None:
|
|
99
|
+
checks.append({'check': f'receipt {packet.stem[:8]} is signed by a known recipient',
|
|
100
|
+
'passed': False, 'file': packet.name,
|
|
101
|
+
'error': 'no recipient this node sent to could have signed it'})
|
|
102
|
+
continue
|
|
103
|
+
destination, obj = found
|
|
104
|
+
checks.append({'check': f'receipt {packet.stem[:8]} is signed by the recipient sent to',
|
|
105
|
+
'passed': obj.get('kind') == 'receipt', 'file': packet.name,
|
|
106
|
+
'proves': 'exit', 'recipient': destination, 'digest': obj.get('digest')})
|
|
107
|
+
return checks
|
dethron/cli.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""The command line: mint an identity, run a relay, send an object, fetch it, check the proofs.
|
|
2
|
+
|
|
3
|
+
Five commands are enough to exercise the whole claim, and none of them hides a step. `send`
|
|
4
|
+
returns a signed custody attestation or says why there is none. `fetch` returns the exact
|
|
5
|
+
bytes or says what is still missing. `verify` recomputes from the files on disk, trusting
|
|
6
|
+
nothing that any earlier command printed.
|
|
7
|
+
|
|
8
|
+
dethron identity ./alice
|
|
9
|
+
dethron relay ./relay --port 45810
|
|
10
|
+
dethron send ./alice --to <contact> --relay <host:port/propagation> report.pdf
|
|
11
|
+
dethron fetch ./bob --from <contact> --relay <host:port/propagation>
|
|
12
|
+
dethron receipts ./alice --relay <host:port/propagation>
|
|
13
|
+
dethron verify ./bob
|
|
14
|
+
"""
|
|
15
|
+
import argparse
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
|
|
22
|
+
from .node import Node, parse_contact, parse_relay
|
|
23
|
+
|
|
24
|
+
ANNOUNCE_SECONDS = 30
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def show(**values):
|
|
28
|
+
print(json.dumps(values, indent=2), flush=True)
|
|
29
|
+
return 0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def identity(args):
|
|
33
|
+
"""An address is knowable before a node ever starts, so contacts can be exchanged first."""
|
|
34
|
+
import RNS
|
|
35
|
+
home = Path(args.home)
|
|
36
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
key = home/'identity'
|
|
38
|
+
existed = key.exists()
|
|
39
|
+
if existed:
|
|
40
|
+
one = RNS.Identity.from_file(str(key))
|
|
41
|
+
else:
|
|
42
|
+
one = RNS.Identity()
|
|
43
|
+
one.to_file(str(key))
|
|
44
|
+
delivery = RNS.Destination.hash(one, 'lxmf', 'delivery').hex()
|
|
45
|
+
return show(home=str(home), created=not existed,
|
|
46
|
+
contact=f'{delivery}.{one.get_public_key().hex()}',
|
|
47
|
+
propagation=RNS.Destination.hash(one, 'lxmf', 'propagation').hex())
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def relay(args):
|
|
51
|
+
"""Hold objects for recipients who are not here yet, and attest to what is held."""
|
|
52
|
+
with Node(args.home, relay=True, port=args.port, peers=args.peer, name='relay') as node:
|
|
53
|
+
show(contact=node.contact, propagation=node.propagation,
|
|
54
|
+
relay=f'<this host>:{args.port}/{node.propagation}')
|
|
55
|
+
print('running; Ctrl-C to stop', file=sys.stderr, flush=True)
|
|
56
|
+
try:
|
|
57
|
+
while True:
|
|
58
|
+
node.announce()
|
|
59
|
+
time.sleep(ANNOUNCE_SECONDS)
|
|
60
|
+
except KeyboardInterrupt:
|
|
61
|
+
print('stopping', file=sys.stderr, flush=True)
|
|
62
|
+
return 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def send(args):
|
|
66
|
+
"""Hand an object to a relay and come back with proof that the relay took it."""
|
|
67
|
+
from .parts import split
|
|
68
|
+
from .protocol import data_envelope, encode
|
|
69
|
+
endpoint, propagation = parse_relay(args.relay)
|
|
70
|
+
destination, _ = parse_contact(args.to)
|
|
71
|
+
content = Path(args.file).read_bytes()
|
|
72
|
+
with Node(args.home, peers=[endpoint], name='origin') as node:
|
|
73
|
+
node.announce()
|
|
74
|
+
origin = node.source.hash.hex()
|
|
75
|
+
part = split(content, 1, hashlib.sha256(content).hexdigest()[:32])[0]
|
|
76
|
+
envelope = data_envelope(origin, destination, encode(part), int(time.time())+args.expires)
|
|
77
|
+
handoff = node.send(encode(envelope), to=args.to, via=propagation, timeout=args.timeout)
|
|
78
|
+
proof = node.custody_of(handoff['transient_id'], relay=args.relay_contact,
|
|
79
|
+
timeout=args.timeout) if args.relay_contact else None
|
|
80
|
+
return show(contact=node.contact, size=len(content),
|
|
81
|
+
sha256=hashlib.sha256(content).hexdigest(),
|
|
82
|
+
transient_id=handoff['transient_id'], packed_bytes=handoff['packed_bytes'],
|
|
83
|
+
proof_of_entry=proof and proof['event'] == 'custody_received',
|
|
84
|
+
custody=proof)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def fetch(args):
|
|
88
|
+
"""Ask a relay for what it holds for us, reconstruct, and write a receipt."""
|
|
89
|
+
endpoint, propagation = parse_relay(args.relay)
|
|
90
|
+
with Node(args.home, receive=True, peers=[endpoint], name='recipient') as node:
|
|
91
|
+
node.announce()
|
|
92
|
+
state = node.fetch(source=args.source, via=propagation, timeout=args.timeout)
|
|
93
|
+
receiver = state.get('receiver') or {}
|
|
94
|
+
if receiver.get('completed') and args.receipt_to:
|
|
95
|
+
node.publish_receipt(to=args.receipt_to, via=propagation, timeout=args.timeout)
|
|
96
|
+
return show(contact=node.contact, completed=bool(receiver.get('completed')),
|
|
97
|
+
sha256=receiver.get('sha256'), output=str(Path(args.home)/'output.bin'),
|
|
98
|
+
sync=state.get('sync'), objects=receiver.get('objects'))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def receipts(args):
|
|
102
|
+
"""Come back after being away and collect the proofs a relay is holding for us."""
|
|
103
|
+
endpoint, propagation = parse_relay(args.relay)
|
|
104
|
+
with Node(args.home, peers=[endpoint], name='origin') as node:
|
|
105
|
+
node.announce()
|
|
106
|
+
state = node.collect(via=propagation, timeout=args.timeout)
|
|
107
|
+
return show(contact=node.contact, collected=state['collected'],
|
|
108
|
+
receipts=state['receipts'], sync=state['sync'])
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def verify(args):
|
|
112
|
+
"""Recompute the proofs held here. The checking itself lives in `audit`, on purpose:
|
|
113
|
+
an auditor that shares a module with what it audits is a formality."""
|
|
114
|
+
from .audit import verify_directory
|
|
115
|
+
report = verify_directory(args.home)
|
|
116
|
+
show(**report)
|
|
117
|
+
return 0 if report['verdict'] != 'fail' else 1
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def build_parser():
|
|
121
|
+
parser = argparse.ArgumentParser(prog='dethron', description=__doc__.split('\n')[0])
|
|
122
|
+
subs = parser.add_subparsers(dest='command', required=True)
|
|
123
|
+
|
|
124
|
+
one = subs.add_parser('identity', help='mint or show a node identity and its contact')
|
|
125
|
+
one.add_argument('home')
|
|
126
|
+
one.set_defaults(run=identity)
|
|
127
|
+
|
|
128
|
+
two = subs.add_parser('relay', help='hold objects for recipients who are not here yet')
|
|
129
|
+
two.add_argument('home')
|
|
130
|
+
two.add_argument('--port', type=int, required=True, help='TCP port to listen on')
|
|
131
|
+
two.add_argument('--peer', action='append', default=[], help='host:port of another node')
|
|
132
|
+
two.set_defaults(run=relay)
|
|
133
|
+
|
|
134
|
+
three = subs.add_parser('send', help='hand an object to a relay and obtain proof of entry')
|
|
135
|
+
three.add_argument('home')
|
|
136
|
+
three.add_argument('file')
|
|
137
|
+
three.add_argument('--to', required=True, help='the recipient contact')
|
|
138
|
+
three.add_argument('--relay', required=True, help='host:port/<propagation address>')
|
|
139
|
+
three.add_argument('--relay-contact', help="the relay's contact, to ask it for custody")
|
|
140
|
+
three.add_argument('--expires', type=int, default=7200, help='seconds until the object expires')
|
|
141
|
+
three.add_argument('--timeout', type=int, default=120)
|
|
142
|
+
three.set_defaults(run=send)
|
|
143
|
+
|
|
144
|
+
four = subs.add_parser('fetch', help='collect what a relay holds and reconstruct it')
|
|
145
|
+
four.add_argument('home')
|
|
146
|
+
four.add_argument('--source', required=True, help="the sender's contact")
|
|
147
|
+
four.add_argument('--relay', required=True, help='host:port/<propagation address>')
|
|
148
|
+
four.add_argument('--receipt-to', help="publish the receipt back to the sender's contact")
|
|
149
|
+
four.add_argument('--timeout', type=int, default=180)
|
|
150
|
+
four.set_defaults(run=fetch)
|
|
151
|
+
|
|
152
|
+
six = subs.add_parser('receipts', help='collect the receipts a relay holds for us')
|
|
153
|
+
six.add_argument('home')
|
|
154
|
+
six.add_argument('--relay', required=True, help='host:port/<propagation address>')
|
|
155
|
+
six.add_argument('--timeout', type=int, default=180)
|
|
156
|
+
six.set_defaults(run=receipts)
|
|
157
|
+
|
|
158
|
+
five = subs.add_parser('verify', help='recompute the proofs held in a node directory')
|
|
159
|
+
five.add_argument('home')
|
|
160
|
+
five.set_defaults(run=verify)
|
|
161
|
+
return parser
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def main(argv=None):
|
|
165
|
+
args = build_parser().parse_args(argv)
|
|
166
|
+
return args.run(args) or 0
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
if __name__ == '__main__':
|
|
170
|
+
raise SystemExit(main())
|
dethron/config.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""The Reticulum configuration a node runs on, written where the node lives.
|
|
2
|
+
|
|
3
|
+
Reticulum reads its configuration from a directory, so a node that carries its own
|
|
4
|
+
directory carries its own network with it: two nodes on one machine do not collide, and
|
|
5
|
+
moving a node means moving a folder. Nothing here is shared and nothing is discovered —
|
|
6
|
+
a node reaches exactly the peers it was told about, which is what makes a claim about
|
|
7
|
+
the medium checkable later.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
BASE = ('[reticulum]\n share_instance = No\n enable_transport = {transport}\n'
|
|
11
|
+
' discover_interfaces = No\n[logging]\n loglevel = {loglevel}\n[interfaces]\n')
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def config_text(*, port=None, peers=(), serial=None, transport=False, loglevel=3):
|
|
15
|
+
"""`port` listens, `peers` are host:port to dial, `serial` is a port that carries no IP.
|
|
16
|
+
|
|
17
|
+
A node given a serial link and nothing else has exactly one interface, and it is not
|
|
18
|
+
an IP one. That is not decoration: it is what lets an audit say an object crossed a
|
|
19
|
+
non-IP medium, because the node had no other medium to cross.
|
|
20
|
+
"""
|
|
21
|
+
if serial and (port or peers):
|
|
22
|
+
raise ValueError('a serial-only node cannot also listen on or dial an IP address')
|
|
23
|
+
text = BASE.format(transport='Yes' if transport else 'No', loglevel=int(loglevel))
|
|
24
|
+
if port:
|
|
25
|
+
text += (' [[Listener]]\n type = TCPServerInterface\n enabled = Yes\n'
|
|
26
|
+
f' listen_ip = 0.0.0.0\n listen_port = {int(port)}\n')
|
|
27
|
+
for index, peer in enumerate(peers):
|
|
28
|
+
host, _, remote = str(peer).partition(':')
|
|
29
|
+
if not host or not remote.isdigit():
|
|
30
|
+
raise ValueError(f'a peer is host:port, not {peer!r}')
|
|
31
|
+
text += (f' [[Peer{index}]]\n type = TCPClientInterface\n enabled = Yes\n'
|
|
32
|
+
f' target_host = {host}\n target_port = {int(remote)}\n')
|
|
33
|
+
if serial:
|
|
34
|
+
text += (' [[Serial]]\n type = SerialInterface\n enabled = Yes\n'
|
|
35
|
+
f" port = {serial['port']}\n speed = {int(serial.get('speed', 115200))}\n"
|
|
36
|
+
' databits = 8\n parity = N\n stopbits = 1\n')
|
|
37
|
+
if not (port or peers or serial):
|
|
38
|
+
raise ValueError('a node with no interface can reach nothing; give a port, a peer or a serial link')
|
|
39
|
+
return text
|
dethron/custody.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Proof of entry: a relay attests, by transient id, that it holds a message.
|
|
2
|
+
|
|
3
|
+
A custody attestation is a claim by the relay. It proves the message was
|
|
4
|
+
confided to the network; it never proves delivery. Only the recipient's
|
|
5
|
+
receipt does that.
|
|
6
|
+
"""
|
|
7
|
+
import hashlib
|
|
8
|
+
import time
|
|
9
|
+
import uuid
|
|
10
|
+
|
|
11
|
+
from RNS.vendor import umsgpack
|
|
12
|
+
|
|
13
|
+
from .protocol import decode, encode
|
|
14
|
+
from .wire import authenticate
|
|
15
|
+
|
|
16
|
+
LIFETIME = 3600
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def request(origin, relay, transient_id, public_key, expires=None, request_id=None):
|
|
20
|
+
obj = {"version": 1, "kind": "custody_request", "id": request_id or uuid.uuid4().hex,
|
|
21
|
+
"source": origin, "destination": relay, "expires": expires or int(time.time())+LIFETIME,
|
|
22
|
+
"transient_id": transient_id, "public_key": public_key}
|
|
23
|
+
return decode(encode(obj), 0)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def attest(req, relay, recipient, stored, received):
|
|
27
|
+
"""Built by the relay from the bytes it actually holds on disk."""
|
|
28
|
+
obj = {"version": 1, "kind": "custody", "id": req["id"], "source": relay,
|
|
29
|
+
"destination": req["source"], "expires": req["expires"],
|
|
30
|
+
"transient_id": req["transient_id"], "recipient": recipient,
|
|
31
|
+
"stored_size": len(stored), "stored_digest": hashlib.sha256(stored).hexdigest(),
|
|
32
|
+
"received": int(received)}
|
|
33
|
+
return decode(encode(obj), 0)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def refuse(req, relay, reason):
|
|
37
|
+
"""Absence of custody must be explicit, never silence."""
|
|
38
|
+
obj = {"version": 1, "kind": "custody_refusal", "id": req["id"], "source": relay,
|
|
39
|
+
"destination": req["source"], "expires": req["expires"],
|
|
40
|
+
"transient_id": req["transient_id"], "reason": reason}
|
|
41
|
+
return decode(encode(obj), 0)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def peek(raw, now):
|
|
45
|
+
"""Untrusted look at the envelope inside a packed LXMF message, only to pick a key."""
|
|
46
|
+
if not isinstance(raw, bytes) or len(raw) < 97:
|
|
47
|
+
raise ValueError("short packet")
|
|
48
|
+
return decode(umsgpack.unpackb(raw[96:])[2], now)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def accept_request(raw, relay, now, known=None):
|
|
52
|
+
"""The requester's key comes from the announce cache or from the request itself;
|
|
53
|
+
either way the LXMF signature must bind that key to the packet's source hash."""
|
|
54
|
+
try:
|
|
55
|
+
key = known if known is not None else bytes.fromhex(peek(raw, now)["public_key"])
|
|
56
|
+
except Exception as exc:
|
|
57
|
+
raise ValueError(f"unreadable custody request: {exc}") from exc
|
|
58
|
+
obj = authenticate(raw, key, relay, now)
|
|
59
|
+
if obj["kind"] != "custody_request" or obj["public_key"] != key.hex():
|
|
60
|
+
raise ValueError("not a custody request signed by its declared key")
|
|
61
|
+
return obj, key
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def verify(raw, relay_public_key, origin, transient_id, now):
|
|
65
|
+
"""Only a signed custody envelope for exactly this transient id counts as proof of entry."""
|
|
66
|
+
obj = authenticate(raw, relay_public_key, origin, now)
|
|
67
|
+
if obj["kind"] != "custody":
|
|
68
|
+
raise ValueError(f"not a custody attestation: {obj['kind']}")
|
|
69
|
+
if obj["transient_id"] != transient_id:
|
|
70
|
+
raise ValueError("custody attests a different message")
|
|
71
|
+
return obj
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def verify_refusal(raw, relay_public_key, origin, transient_id, now):
|
|
75
|
+
obj = authenticate(raw, relay_public_key, origin, now)
|
|
76
|
+
if obj["kind"] != "custody_refusal" or obj["transient_id"] != transient_id:
|
|
77
|
+
raise ValueError("not a refusal for this message")
|
|
78
|
+
return obj
|
dethron/database.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Bounded application mailbox, independent from the native LXMF propagation store."""
|
|
2
|
+
from contextlib import contextmanager
|
|
3
|
+
import sqlite3
|
|
4
|
+
|
|
5
|
+
from .protocol import encode
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def row_key(direction, obj):
|
|
9
|
+
return "/".join([direction, obj["source"], obj["destination"], obj["id"], obj["kind"]])
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Database:
|
|
13
|
+
def __init__(self, path, identity, max_rows=128, max_bytes=8*1024*1024):
|
|
14
|
+
self.path, self.identity = path, identity
|
|
15
|
+
self.max_rows, self.max_bytes = max_rows, max_bytes
|
|
16
|
+
with self.transaction() as db:
|
|
17
|
+
db.execute("CREATE TABLE IF NOT EXISTS metadata (identity TEXT, version INTEGER)")
|
|
18
|
+
row = db.execute("SELECT identity,version FROM metadata").fetchone()
|
|
19
|
+
if row and tuple(row) != (identity, 1):
|
|
20
|
+
raise ValueError("mailbox identity/schema mismatch")
|
|
21
|
+
if not row:
|
|
22
|
+
db.execute("INSERT INTO metadata VALUES (?,1)", (identity,))
|
|
23
|
+
db.execute("""CREATE TABLE IF NOT EXISTS messages (
|
|
24
|
+
key TEXT PRIMARY KEY, direction TEXT, body BLOB NOT NULL,
|
|
25
|
+
state TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0,
|
|
26
|
+
wire BLOB NOT NULL DEFAULT X'')""")
|
|
27
|
+
|
|
28
|
+
@contextmanager
|
|
29
|
+
def transaction(self):
|
|
30
|
+
db = sqlite3.connect(self.path, timeout=5, isolation_level=None)
|
|
31
|
+
db.row_factory = sqlite3.Row
|
|
32
|
+
try:
|
|
33
|
+
db.execute("PRAGMA journal_mode=DELETE")
|
|
34
|
+
db.execute("PRAGMA synchronous=FULL")
|
|
35
|
+
db.execute("BEGIN IMMEDIATE")
|
|
36
|
+
yield db
|
|
37
|
+
db.commit()
|
|
38
|
+
except BaseException:
|
|
39
|
+
db.rollback()
|
|
40
|
+
raise
|
|
41
|
+
finally:
|
|
42
|
+
db.close()
|
|
43
|
+
|
|
44
|
+
def insert(self, db, direction, obj, state, wire=b""):
|
|
45
|
+
key, body = row_key(direction, obj), encode(obj)
|
|
46
|
+
existing = db.execute("SELECT body FROM messages WHERE key=?", (key,)).fetchone()
|
|
47
|
+
if existing:
|
|
48
|
+
if existing["body"] != body:
|
|
49
|
+
raise ValueError("message ID conflicts with persisted content")
|
|
50
|
+
return False
|
|
51
|
+
count, used = db.execute("SELECT COUNT(*), COALESCE(SUM(length(body)+length(wire)),0) FROM messages").fetchone()
|
|
52
|
+
if count >= self.max_rows or used+len(body)+len(wire) > self.max_bytes:
|
|
53
|
+
raise ValueError("mailbox capacity exceeded")
|
|
54
|
+
db.execute("INSERT INTO messages(key,direction,body,state,wire) VALUES (?,?,?,?,?)",
|
|
55
|
+
(key, direction, body, state, wire))
|
|
56
|
+
return True
|