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 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