From 6d276be51ba89b04a60bb16b5179c015ad202c7c Mon Sep 17 00:00:00 2001 From: eunhhu Date: Sun, 27 Sep 2026 19:17:02 +0100 Subject: Seal in one session a wire, authenticated by the sender's key (088) A sealed request cost three X25519 operations (about 0.5 ms), so sealed sends drained at half the plain rate. A wire now keeps one HPKE context, encapsulated at its first seal, and seals each request as the context's next number (a failed seal or a restart starts a new session); the header carries the session's encapsulated key and the number, so a receiver opens a session once per connection thread (16 remembered, the least used replaced) and each seal by its number, also after it restarts, unasked. A sender with a key of its own seals in HPKE auth mode and names that key in the header; a receiver started with --sender NAME=KEY takes deliveries only from the senders it names, each sealed with its key (403 sender otherwise). The body is now the ciphertext and its tag. Sealed sends, sender authenticated, drain at 461-464k a second against 462-470k plain and 207-234k sealed a request at a time (Raspberry Pi 5). The check adds: a wire's requests share one session numbered from 0; a changed number or sender key does not open; a receiver restarted naming its sender opens the same session again; another node claiming that sender's name with its own key is refused and applies nothing. --- tests/check.py | 110 ++++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 74 insertions(+), 36 deletions(-) (limited to 'tests/check.py') diff --git a/tests/check.py b/tests/check.py index b80bf94..76a23e3 100644 --- a/tests/check.py +++ b/tests/check.py @@ -769,12 +769,17 @@ def server_links(aup, pure, work): def server_sealed(aup, pure, work): - """Sends sealed end to end (--seal to the receiver's --key): through a relay - that records what passes, every order is applied once and the relay saw - no item and no field name; one byte changed on the way is refused (400 - seal) and applies nothing, the recorded request sent again applies - nothing twice, an unsealed delivery is refused (403) by a node that keeps - its deliveries sealed, and a seal to another key does not open.""" + """Sends sealed end to end (--seal to the receiver's --key), in one session + a wire and authenticated by the sender's own --key: through a relay that + records what passes, every order is applied once, the relay saw no item + and no field name, and the wire's requests share one session, numbered + in order. One byte changed on the way, another number or another + sender's key is refused (400 seal) and applies nothing; the recorded + request sent again applies nothing twice; an unsealed delivery is + refused (403) by a node that keeps its deliveries sealed; a seal to + another key does not open. Restarted naming its sender (--sender), the + receiver opens that sender's session again unasked, and refuses another + node claiming the sender's name with its own key (403 sender).""" import select import threading import time @@ -807,12 +812,25 @@ def server_sealed(aup, pure, work): finally: connection.close() + def buy(port, item, count): + assert request(port, {'Content-Type': 'application/json'}, json.dumps({'item': item, 'count': count}).encode(), + '/inputs/buy')[0] == 200 + + def wait_for(condition, seconds=20): + deadline = time.time() + seconds + while not condition() and time.time() < deadline: + time.sleep(0.05) + return condition() + + def count(): + return int(http(port_b, 'GET', '/views/count')[3]) + key = work / 'seal-receiver.key' receiver, started = start([pure / 'build/pure-serve', '--module', receiver_module, '--store', work / 'seal-b', '--key', key, '--sealed-only']) port_b, public_key = started['port'], started['public_key'] assert len(public_key) == 64 and (key.stat().st_mode & 0o777) == 0o600, (public_key, oct(key.stat().st_mode)) - seen = [] + seen = [] # (headers, body, status, X-Pure-Error) of each request relayed class Relay(BaseHTTPRequestHandler): protocol_version = 'HTTP/1.1' @@ -821,11 +839,11 @@ def server_sealed(aup, pure, work): body = self.rfile.read(int(self.headers['Content-Length'])) headers = {name: value for name, value in self.headers.items() if name.lower().startswith('x-pure-') or name.lower() in ('content-type', 'idempotency-key')} - seen.append((headers, body)) connection = HTTPConnection('127.0.0.1', port_b, timeout=10) connection.request('POST', self.path, body, headers) answer = connection.getresponse() data = answer.read() + seen.append((headers, body, answer.status, answer.getheader('X-Pure-Error'))) self.send_response(answer.status) for name, value in answer.getheaders(): if name.lower().startswith('x-pure-'): @@ -841,58 +859,78 @@ def server_sealed(aup, pure, work): relay = ThreadingHTTPServer(('127.0.0.1', 0), Relay) port_relay = relay.server_address[1] threading.Thread(target=relay.serve_forever, daemon=True).start() + wire = f'ordered=http://127.0.0.1:{port_relay}/inputs/order' sender, sender_started = start([pure / 'build/pure-serve', '--module', sender_module, '--store', work / 'seal-a', - '--send', f'ordered=http://127.0.0.1:{port_relay}/inputs/order', - '--seal', f'ordered={public_key}']) - port_a = sender_started['port'] - stranger = None + '--key', work / 'seal-sender.key', '--send', wire, '--seal', f'ordered={public_key}']) + port_a, sender_key = sender_started['port'], sender_started['public_key'] + stranger = impostor = None try: orders, total = 60, 0 for i in range(orders): - count = i % 7 + 1 - total += count - assert request(port_a, {'Content-Type': 'application/json'}, - json.dumps({'item': f'secret-{i}', 'count': count}).encode(), '/inputs/buy')[0] == 200 - deadline = time.time() + 20 - while int(http(port_b, 'GET', '/views/count')[3]) < orders and time.time() < deadline: - time.sleep(0.05) - assert int(http(port_b, 'GET', '/views/count')[3]) == orders + total += i % 7 + 1 + buy(port_a, f'secret-{i}', i % 7 + 1) + assert wait_for(lambda: count() == orders), count() assert int(http(port_b, 'GET', '/views/total')[3]) == total - assert seen and all('X-Pure-Seal' in headers for headers, _ in seen), seen[:1] - assert not any(b'secret' in body or b'item' in body or b'city' in body for _, body in seen) - headers, body = seen[0] + seals = [headers['X-Pure-Seal'].split() for headers, _, _, _ in seen] + assert seen and all(len(parts) == 4 and parts[3] == sender_key for parts in seals), seals[:1] + assert len({parts[1] for parts in seals}) == 1 # one session + numbers = [int(parts[2]) for parts in seals] + assert numbers == list(range(len(numbers))), numbers + assert all(status == 200 for _, _, status, _ in seen) + assert not any(b'secret' in body or b'item' in body or b'city' in body for _, body, _, _ in seen) + headers, body, _, _ = seen[0] + suite, enc, n, _ = headers['X-Pure-Seal'].split() changed = bytearray(body) changed[len(changed) // 2] ^= 1 assert request(port_b, headers, bytes(changed)) == (400, 'seal') + renumbered = dict(headers, **{'X-Pure-Seal': f'{suite} {enc} {int(n) + 1} {sender_key}'}) + assert request(port_b, renumbered, body) == (400, 'seal') + other_sender = f'{suite} {enc} {n} {public_key}' + assert request(port_b, dict(headers, **{'X-Pure-Seal': other_sender}), body) == (400, 'seal') assert request(port_b, headers, body)[0] == 200 # heard before: applied once plain = {name: value for name, value in headers.items() if name != 'X-Pure-Seal'} assert request(port_b, plain, json.dumps({'item': 'plain', 'count': 1, 'to': {'city': 'x'}}).encode()) == \ (403, 'unsealed') + assert count() == orders # A sender sealing to another node's key: the receiver cannot open it. other, other_started = start([pure / 'build/pure-serve', '--module', receiver_module, '--store', work / 'seal-c', '--key', work / 'seal-other.key']) other.terminate() other.wait() stranger, stranger_started = start([pure / 'build/pure-serve', '--module', sender_module, '--store', - work / 'seal-d', '--send', f'ordered=http://127.0.0.1:{port_relay}/inputs/order', - '--seal', f'ordered={other_started["public_key"]}']) + work / 'seal-d', '--send', wire, '--seal', + f'ordered={other_started["public_key"]}']) before = len(seen) - assert request(stranger_started['port'], {'Content-Type': 'application/json'}, - json.dumps({'item': 'stranger', 'count': 5}).encode(), '/inputs/buy')[0] == 200 - deadline = time.time() + 5 - while len(seen) == before and time.time() < deadline: - time.sleep(0.05) - time.sleep(0.2) - assert len(seen) > before and int(http(port_b, 'GET', '/views/count')[3]) == orders - # The same key file gives the same public key after a restart. + buy(stranger_started['port'], 'stranger', 5) + assert wait_for(lambda: len(seen) > before, 5) + assert seen[before][2:] == (400, 'seal') and len(seen[before][0]['X-Pure-Seal'].split()) == 3 + assert count() == orders + # Restarted naming its sender: the same key opens the sender's session + # again, and another node claiming the sender's name is refused. + source = seen[0][0]['X-Pure-Source'] receiver.terminate() receiver.wait() receiver, again = start([pure / 'build/pure-serve', '--module', receiver_module, '--store', work / 'seal-b', - '--key', key, '--sealed-only']) + '--key', key, '--sender', f'{source}={sender_key}']) + port_b = again['port'] assert again['public_key'] == public_key - return {'orders': orders, 'relayed': before, 'suite': seen[0][0]['X-Pure-Seal']} + before = len(seen) + buy(port_a, 'after', 2) + assert wait_for(lambda: count() == orders + 1), count() + assert seen[-1][0]['X-Pure-Seal'].split()[1] == enc # the same session, opened again unasked + impostor, impostor_started = start([pure / 'build/pure-serve', '--module', sender_module, '--store', + work / 'seal-e', '--key', work / 'seal-impostor.key', '--send', wire, + '--seal', f'ordered={public_key}']) + before = len(seen) + buy(impostor_started['port'], 'impostor', 9) + assert wait_for(lambda: any(h['X-Pure-Seal'].split()[3] == impostor_started['public_key'] + for h, _, _, _ in seen[before:]), 5) + claimed = [entry for entry in seen[before:] if entry[0]['X-Pure-Seal'].split()[3] != sender_key] + assert claimed and claimed[0][0]['X-Pure-Source'] == source and claimed[0][2:] == (403, 'sender'), claimed[:1] + assert count() == orders + 1 + return {'orders': orders, 'relayed': len(seen), 'suite': suite, 'sessions': 1} finally: - for process in (sender, receiver, stranger): + for process in (sender, receiver, stranger, impostor): if process: process.terminate() process.wait() -- cgit v1.2.3