lazynton
E2EE HTTP client for lazynton (axum) servers. rhttp transport, X25519 handshake, XChaCha20-Poly1305 binary bodies.
- Binary wire format:
application/octet-streambody = rawnonce(24) || ciphertext || tag(16)— raw bytes end to end, no hex on the hot path - Binary handshake:
POST /handshakesends 32 raw key bytes and reads 52 back (lazynton >= 0.3); falls back to the older JSON handshake automatically - Per-session keys, addressed by the
X-Session-Idheader, renewed just before the server'sexpiresIn; auto re-handshake + one retry on 401 - Pre-shared key mode: pass
sharedKeyto skip the handshake (server fallback-key mode)
Usage
import 'package:lazynton/lazynton.dart';
await Rhttp.init(); // once at app startup
final client = await LazyntonClient.create(baseUrl: 'https://api.example.com');
// Handshakes lazily on first call; encrypts request, decrypts response.
final data = await client.post('/data', {'msg': 'hi'});
Pre-shared key (no handshake):
final client = await LazyntonClient.create(
baseUrl: 'https://api.example.com',
sharedKey: 'edf9d004edae8335f095bb8e01975c42cf693ea60322b75cb7c6667dc836fd7e',
);
Already-encoded payloads (protobuf, msgpack, a file) skip the JSON layer:
final Uint8List? reply = await client.postBytes('/upload', bytes);
Non-2xx responses throw RhttpStatusCodeException (server errors pass through
unencrypted); a body that fails authentication throws
LazyntonDecryptException. Tune the transport with
settings: ClientSettings(...).
Server compatibility
| server | handshake |
|---|---|
| lazynton >= 0.3 | binary — 32 bytes up, sessionId(16) || serverPublicKey(32) || expiresIn(u32 be) down |
| lazynton < 0.3 | JSON — {"clientPublicKey"} → {"sessionId", "serverPublicKey", "expiresIn"}, all hex |
HandshakeFormat.auto (the default) tries binary and latches onto JSON if the
server rejects it, so one build talks to both. Pin it with
handshakeFormat: HandshakeFormat.binary once every server is on 0.3.
Performance
The AEAD is the floor; everything this package does around it is about not paying anything else. Bodies are raw bytes on the wire, so a request costs one allocation (the wire buffer) and a response costs none — the ciphertext is decrypted in place inside the buffer rhttp already returned. The session key is decoded from hex once per handshake, not once per call, and JSON is converted straight to and from UTF-8 bytes without an intermediate string.
flutter test benchmark/wire_benchmark.dart on an Apple M-series, JSON object
to wire and back, ns/op. The absolute numbers are JIT — release AOT is several
times faster — but the ratio against the pre-0.1.0 hex path holds:
| payload | encrypt via hex | binary | decrypt via hex | binary |
|---|---|---|---|---|
| 128 B | 72444 | 13445 (5.4x) | 45430 | 9768 (4.7x) |
| 1 KB | 77389 | 30595 (2.5x) | 76609 | 27165 (2.8x) |
| 16 KB | 717005 | 411703 (1.7x) | 688505 | 372460 (1.8x) |
| 64 KB | 2653925 | 1631695 (1.6x) | 2698275 | 1513300 (1.8x) |
The cipher itself is pure Dart: cryptography_flutter has no accelerated
XChaCha20, and only the Dart implementation exposes the in-place buffer path
above. That makes large bodies a frame-budget question, so payloads at or above
isolateThreshold (64 KB by default, matching the server's own default body
cap) are encrypted and decrypted on a background isolate instead of the calling
one. Pass isolateThreshold: null to keep everything inline, or lower it if
your server accepts bigger bodies and you see jank.
Encrypting outside the client — a cached payload, a background upload — uses the same codec directly:
final cipher = SessionCipher.fromHex(keyHex)!;
final wire = cipher.encrypt(utf8.encode('...'));
final plain = cipher.decrypt(wire); // consumes `wire`