SPEC-TRANSPORT-ENC.md

In docs/specs/

Preview

Text · 20.1 KiB

spec: SPEC-TRANSPORT-ENC version: 1 status: draft audience: implementers of mkit encrypted-stream clients and servers

SPEC-TRANSPORT-ENC — mkit encrypted-stream transport

mkit-transport-enc is the encrypted-stream sibling of mkit-transport-ssh. It implements the same seven-verb Transport trait, exchanging the same SshFrame protobuf messages, but over an authenticated, encrypted byte stream provided by commonware-stream::encrypted instead of a system ssh(1) child process.

The motivation is to give mkit a self-contained "always-encrypted" transport for environments where shelling out to OpenSSH is awkward (WASM, embedded targets, browser-hosted Workers) while keeping a single source of truth for verb-level framing across SSH and encrypted paths.

The transport has two forms: a deterministic in-process scaffold (EncSession/from_session) exercised by the in-tree round-trip test suite, and the real TCP transport used in production — URL parsing, connect_tcp/serve_tcp, the mkit-cli/enc-transport feature gate, and the serve_tcp_* listener helpers. The transport is deprecated: no maintained server hosts a listener (use SSH mkit serve or Connect). §6 describes the TCP transport's mechanics and its current limitations.


1. URL scheme

Scheme prefixTransportNotes
mkit+enc://[user@]host[:port][/path]?pubkey=<hex-or-b64url>mkit-transport-encImplemented by mkit_transport_enc::url::parse_enc_url. The user@ and /path components are accepted for round-tripping but not consulted by the handshake; trust is via the ?pubkey= payload only.

The URL carries the server's static ed25519 public key as a query-parameter pubkey=<…> in one of two equivalent encodings:

  • Hex: 64 lowercase or uppercase hex digits (32 bytes raw).
  • URL-safe base64, no padding: 43 chars, alphabet [A-Za-z0-9-_]. The trailing 2 bits of the final base64 character MUST be zero; otherwise the parser rejects the URL to prevent two distinct strings from decoding to the same payload.

Trust is established by out-of-band knowledge of this key — there is no fall-back to a TOFU-style first-use cache, and no CA chain. If the server's actual key during handshake differs from the URL-advertised key, the dialer aborts with EncInitError::PeerRejected (mapped from commonware_stream::encrypted::Error::PeerRejected).

The default TCP port when the URL omits one is 9418 (same as git's daemon, reused for operator familiarity).

1.1 Examples

mkit+enc://h.example?pubkey=0000…0000             # bare host + hex
mkit+enc://h.example:7777?pubkey=0000…0000        # explicit port
mkit+enc://alice@h.example/projects/x?pubkey=…    # user + path (round-trip only)
mkit+enc://h.example?pubkey=AAAAAA…AAA            # b64-url, 43 chars

2. Cryptographic layer

The encrypted layer is provided verbatim by [commonware_stream::encrypted] (pinned to the commonware train fixed in rust/Cargo.toml, =2026.9.0 as of this revision). mkit does not implement its own handshake or AEAD; this section documents the parameters mkit configures.

2.1 Handshake parameters

FieldValueRationale
namespaceb"mkit/transport-enc/v1"Pre-shared transcript binding. Prevents cross-application replay if peers share ed25519 keys with another commonware-stream user. Bumping the /v1 suffix is the lever for a hard cryptographic break in future.
max_message_sizemkit_rpc::MAX_FRAME_BYTES (1 MiB)Matches the inner SshFrame framing cap so a maximally-sized verb fits in one encrypted record. Removes an off-by-overhead class of bugs where one limit sneaks past the other.
synchrony_bound30 sTolerable clock skew between client and server. Generous, sized to keep flaky-CI failures out of the in-tree round-trip test; a production deployment operating over real, higher-latency networks SHOULD configure a tighter bound (≤ 5 s).
max_handshake_age30 sSame envelope as synchrony_bound; the same tightening applies for production use.
handshake_timeout60 sHard ceiling for handshake completion. Generous for the in-process test scaffold; a production deployment SHOULD tighten to ≤ 10 s.

2.2 Identity

Both peers carry an ed25519 static keypair. mkit chose ed25519 (over BLS) for the in-process scaffold because:

  1. The keys are small (32-byte public, 32-byte private), matching the shape of SSH host keys mkit operators already manage.
  2. Verification is constant-time and fast, suitable for browser-hosted clients, including a Workers-hosted deployment.
  3. commonware-cryptography::ed25519 is already a stable Signer and needs no application glue.

A later revision may add BLS as an optional alternative (the commonware_stream::encrypted API is generic over Signer), but the default — and the only form covered by the in-process scaffold's CLI — is ed25519.

2.3 Properties

Inherited from commonware-stream::encrypted (verbatim from its documented guarantees):

  • Mutual authentication via static-key signatures over the handshake transcript.
  • Forward secrecy via ephemeral X25519.
  • Per-record nonce derivation — ChaCha20-Poly1305 nonces are a monotonic counter, never transmitted on the wire.
  • Session uniqueness — SynAck is transcript-bound to Syn, preventing replay across sessions.
  • Handshake timeout — see §2.1.

Not provided (also inherited):

  • Anonymity — peer identities are exchanged in cleartext during the handshake. mkit clients embed mkit/<version> in the application-level Hello frame anyway, so nothing is lost.
  • Padding — message lengths leak. Pack-upload chunks are 800 KiB each, so the leak ceiling is "this verb is or isn't a pack body chunk", which the SSH transport also reveals.

3. Application layer

The application protocol on top of the encrypted stream is the same SshFrame protobuf message set used by mkit-transport-ssh, defined in ssh.proto. Verb semantics, error mapping, ref-CAS encoding, and pack-streaming chunk boundaries are byte-for-byte identical to SPEC-TRANSPORT §4.

3.1 Framing — encrypted records, not length-prefixed bytes

The one deliberate departure from the SSH transport's wire is the inner framing. mkit-transport-ssh wraps each SshFrame in a 4-byte LE u32 length prefix (defined in mkit-rpc/src/framing.rs) because SSH's stdin/stdout pipe is an unframed byte stream.

commonware-stream::encrypted already frames each ciphertext record (one varint length prefix per Sender::send call), so the inner SshFrame rides as a single protobuf payload per encrypted record — no second length prefix. One SshFrame send produces exactly one Sender::send, and one Receiver::recv returns exactly one SshFrame's worth of bytes.

Concretely:

┌──────────────────────────────────────────────┐
│ SshFrame protobuf body (verb message)        │
├──────────────────────────────────────────────┤
│ ChaCha20-Poly1305 ciphertext + 16-byte tag   │  per direction, counter nonce
├──────────────────────────────────────────────┤
│ commonware-stream varint length prefix       │  framing
├──────────────────────────────────────────────┤
│ Sink / Stream byte transport                 │  TCP (production) or mocks::Channel (in-process test scaffold)
└──────────────────────────────────────────────┘

The 1 MiB ceiling on MAX_FRAME_BYTES is enforced twice — once by commonware-stream via max_message_size (see §2.1), once by every SshFrame consumer that already exists. Either layer will reject a non-conforming peer.

3.2 Hello handshake

After the encrypted handshake completes, the client MUST send a Hello frame with proto = PROTOCOL_VERSION_1 and client_id = "mkit <semver>". The server MUST reply with a HelloResponse whose proto matches. If either side disagrees, both close the connection without further verb exchange. As over ssh (SPEC-TRANSPORT §4.2), a server answers a first frame that is not Hello with Error{INVALID_REQUEST, "first frame must be Hello"}, and a Hello for another version with Error{INVALID_REQUEST, "unsupported proto_version <n>"}, before it closes; it serves no verb on that connection. (The former mkit serve --listen-enc closed without replying.)

This Hello is layered on top of the encrypted channel, not inside the encrypted handshake. It mirrors what mkit-transport-ssh does and keeps the protocol-version dance in one place.


4. Resource caps

CapValueSource
Max single encrypted record1 MiBmkit_rpc::MAX_FRAME_BYTES
Max ref name / prefix512 bytes (SPEC-REFS §3)MAX_REF_NAME in mkit-rpc/src/helpers.rs (re-exported by mkit-transport-enc), equal to mkit_core::refs::MAX_REF_NAME_BYTES
Per-chunk pack data800 KiBCHUNK_DATA_MAX; same as mkit-transport-ssh
Pack body totalmkit_core::protocol::PACK_BODY_LIMITShared client cap

5. Test plan

The in-tree test suite lives in mkit-transport-enc/src/lib.rs under #[cfg(test)] mod tests. Tests run inside a single commonware_runtime::deterministic::Runner so they exercise the same async code paths the TCP transport's tokio wiring hits, without depending on wall-clock time or a multi-threaded executor.

TestWhat it pins
hello_and_list_refs_roundtrip_over_ciphertextEnd-to-end: encrypted handshake → app Hello → ListRefs round-trip → assert two refs decoded. Also captures bytes leaving the dialer's Sink and asserts the literal "refs/heads/" plaintext does NOT appear in the capture — the bytes on wire are ChaCha20-Poly1305 ciphertext.
handshake_rejection_surfaces_peer_rejectedServer's bouncer unconditionally returns false; client's dial MUST resolve to an error and the server-side outcome MUST be EncryptedError::PeerRejected.
peer_rejected_error_maps_to_init_errorPure unit test: EncryptedError::PeerRejected(_) → EncInitError::PeerRejected. Catches regressions in the From impl even if a future commonware release moves which side surfaces the rejection.
url::parse_enc_url::* (~25 cases)URL parser: pins accepted forms (mkit+enc://[user@]host[:port][/path]?pubkey=<hex|b64url>), both pubkey encodings, and rejection of bad inputs (missing prefix / pubkey, port overflow, CRLF / NUL injection, .. path segments, b64 trailing-bit ambiguity, duplicate / unknown query params).
tcp::executor_handles_repeated_block_onTokioExecutor::block_on is safe to call repeatedly: a task spawned during the first block_on is still alive when a later block_on awaits it, so a refactor that drops and rebuilds the runtime between calls fails the test.
tcp_e2e::list_refs_round_trip_over_real_tcp (gated on --features tcp)End-to-end TCP: real TcpListener on a free port, real connect_tcp dialer via an in-test byte-sniffing proxy. Asserts (a) Transport::list_refs round-trip succeeds and (b) the proxy never observes the literal "refs/heads/" prefix the client sent — bytes on wire are ChaCha20-Poly1305 ciphertext.

6. TCP transport mechanics and known limitations

6.1 Mechanics

mkit_transport_enc::url::parse_enc_url accepts mkit+enc://[user@]host[:port][/path]?pubkey=<hex|b64url> per §1. EncTransport::connect_tcp opens a tokio TcpStream, drives commonware_stream::encrypted::dial against the URL-advertised peer pubkey, and returns a fully-wired synchronous Transport. The dial is single-roundtrip; verb calls block_on a per-call future through the TokioExecutor. Custom Sink/Stream wrappers (tokio_io::{TokioSink, TokioStream}) adapt tokio's OwnedReadHalf / OwnedWriteHalf to commonware_runtime::{Sink, Stream} because the upstream pub(crate) types aren't reachable from outside the runtime crate.

remote_dispatch::open recognizes the mkit+enc:// scheme behind the mkit-cli/enc-transport cargo feature; default builds remain SSH-only.

The standalone listener (mkit-server serve --listen-enc) was removed with the native server, and the CLI's former mkit serve --listen-enc before it; no maintained server hosts mkit+enc://. The library keeps the pieces a listener is built from: mkit_transport_enc::serve_tcp_listener, the async accept loop, runs on the embedder's runtime, and each session can be served with mkit_server::ssh::serve_session over a pipeline, as the TransportPeer principal holding the key the handshake authenticated; its verb replies are therefore the ssh session's (§3). Under multi-repository addressing such a listener binds every session to one configured repository (SPEC-TRANSPORT-CONNECT §7.4): nothing in the session's frames can select another.

A listener MUST be fail-closed (issue #178): it refuses to serve unless it is given an allowlist of client public keys or an explicit allow-any development escape. The accept loop consults a PeerPolicy — AllowAny (dev / the explicit unsafe escape) or Allowlist(HashSet<[u8;32]>) (one client pubkey per line in the file form, 64-hex or 43-char url-safe base64; # comments and blank lines ignored). serve_tcp_with_addr retains AllowAny for the direct e2e harness only. The allowlist bouncer rejects any unlisted dialer at the handshake — a rejected peer never receives a HelloResponse, list-refs, packs, or update-ref.

A listener bounds each client: a handshake deadline (§2.1, default 10 s), a per-frame idle timeout after it (applied to every frame read and write), and the per-connection budgets of SPEC-TRANSPORT §4.4. A listener caps the connections in the handshake apart from the sessions, so clients that connect and never handshake cannot hold the slots of authorized ones. On shutdown it stops accepting and ends each session at its next frame boundary (never inside an upload), within its grace period.

The server identity is a stable raw-32 key loaded, or created on first run, from an operator-supplied path (there is no default path), so the advertised ?pubkey= is stable across restarts; only the unsafe allow-any mode without a key file keeps an ephemeral per-process key. A client can similarly pin its identity via the MKIT_ENC_CLIENT_KEY environment variable (a user-scoped or CLI-supplied raw-32 key file) so an allowlisting server can pin the client across restarts; when unset, the client derives an ephemeral per-process key, which only an allow-any server will accept. Peer-authorization and identity key paths are CLI-supplied or user-scoped and are never read from repo-local .mkit/config.

connect_tcp lazily bootstraps a commonware_runtime::BufferPool by spinning up a one-shot commonware tokio Runner on a fresh OS thread, then dropping the bootstrap runtime while holding an Arc clone of the pool; the pool is cached process-wide. This exists because commonware-runtime does not currently expose a public BufferPool::new_for_network() constructor.

6.2 Known limitations

Server and client identities are stable raw-32 key files on disk (a listener's server key and MKIT_ENC_CLIENT_KEY), not yet routed through mkit-keystore the way SSH host keys and signing keys are; keystore integration would also let the public connect_tcp signature take a keystore-backed key type instead of a raw one. The allowlist is a flat authorized-peers file, not a keystore partition.

6.3 Retry / reconnect

EncTransport is connection-oriented (a single long-lived encrypted session), so it follows the same reconnect-before-retry pattern as SshTransport (SPEC-TRANSPORT §4.5) rather than the request-per-attempt shape HTTP/S3 use. Orphan rules prevent mapping commonware_stream::encrypted::Error variants individually, so stream_err collapses every cipher-layer I/O failure — a send, a receive, a decrypt — to TransportError::ConnectionFailed; any such failure can leave the session's per-record nonce counters desynced between client and peer, so resuming on the same session is never safe once a failure is observed.

Every verb is driven through [mkit_core::protocol::retrying]. A ConnectionFailed marks the session dead; the next attempt calls ensure_connected, which redials through the transport's ReconnectFn (when one is wired — tcp::connect_tcp wires a real redial closure that reruns the full dial and commonware_stream::encrypted::dial handshake) and redoes the application Hello/HelloResponse exchange before the verb is re-issued against the fresh session. A transport built directly from an already-established session with no redial capability (for example the in-process test harness) has no ReconnectFn wired, so a dead session surfaces ConnectionFailed immediately instead of retrying — matching pre-retry behavior for those callers. As with SSH, upload_pack is safe to resend in full on the fresh session; update_ref is not idempotent across retries per SPEC-TRANSPORT §7, so a retried CAS write still requires the caller's read_ref disambiguation on RefConflict.


7. Versioning

Application protocol version is the same as the rest of mkit (PROTOCOL_VERSION_1). The cryptographic-namespace prefix (mkit/transport-enc/v1) is bumped independently if the handshake or record layer needs a hard break that the application-level HelloResponse mismatch can't catch.


8. Invariants

InvariantEnforced by
The peer is the key the URL advertises, or no session exists?pubkey= carried out-of-band in the URL; mismatch → EncInitError::PeerRejected; no TOFU cache, no CA fallback (§1)
Two distinct URL strings cannot decode to the same pubkeyhex (exactly 64 chars) or unpadded b64url (43 chars, trailing 2 bits zero) — ambiguous encodings rejected at parse (§1)
Mutual authentication and forward secrecystatic-key signatures over the handshake transcript; ephemeral X25519 (§2.3)
No nonce reuse, no cross-session replayper-record counter nonces never on the wire; SynAck transcript-bound to Syn (§2.3)
No cross-application replay of handshakesnamespace = b"mkit/transport-enc/v1" transcript binding (§2.1)
No frame exceeds 1 MiB — enforced twicemax_message_size = mkit_rpc::MAX_FRAME_BYTES at the record layer (§2.1) and by every existing SshFrame consumer (§3.1)
One SshFrame per encrypted record, no framing ambiguitysingle protobuf payload per Sender::send; no second length prefix (§3.1)
No verb exchanged before version agreementpost-handshake Hello/HelloResponse with PROTOCOL_VERSION_1; disagreement closes the connection (§3.2)
The listener is fail-closed: an unlisted peer gets nothingbinding requires an authorized-peers allowlist (or an explicit allow-any escape); rejected peers never receive a HelloResponse, refs, or packs (§6.1)
Verb semantics never diverge from the SSH transportsame SshFrame message set; semantics byte-for-byte per SPEC-TRANSPORT §4 (§3)
Application plaintext never appears on the wirepinned by the byte-sniffing round-trip tests (§5)
A ConnectionFailed never resumes writes/reads on the same (possibly desynced) sessionevery verb driven through retrying; a dead session is redialed and re-Hello'd before the next attempt, never reused (§6.3)

Explicitly not guaranteed, inherited from the stream layer: anonymity (peer identities exchanged in cleartext) and length padding (message sizes leak) (§2.3).

Files