SPEC-RPC.md

In docs/specs/

Preview

Text · 12.4 KiB

spec: SPEC-RPC version: 1 status: stable-normative audience: integrators implementing an mkit external signer or mkit-server

SPEC-RPC — mkit cross-system wire protocol v1

mkit speaks to processes outside its address space — external signers running as subprocesses, and remote mkit-server instances reached over ssh(1) — using a single shared wire protocol.

The schemas live in rust/crates/mkit-rpc/proto/:

FilePurpose
common.protoShared types: Algorithm, KeyForm, ErrorCode, Error, ProtocolVersion
signer.protoExternal-signer protocol (SignerFrame oneof). See SPEC-EXTERNAL-SIGNER for prose.
ssh.protoSSH transport protocol (SshFrame oneof). See SPEC-TRANSPORT for prose.
verify.protoObject-signature verification contract (VerifyRequest/VerifyResponse, issue #692). Not a framed protocol under §1/§4 below — it is message-only, with no oneof-dispatched frame and no bound RPC method yet. mkit-cli's local dispatch (rust/crates/mkit-cli/src/remote_dispatch/packmap.rs) calls the Rust implementation directly (mkit_core::sign::{verify_commit,verify_remix,verify_tag}); the schema exists so a future ConnectRPC transport (for example apps/repo-worker) can bind the identical check to a service method instead of reimplementing it.

ssh.proto imports the CAS enum (RefExpectation) and the ref-listing type (RefEntry) from mkit/common/v1/refs.proto at the repo root — a vocabulary shared with mkit.repo.v1 (the apps/repo-worker multiplayer demo protocol), so the two wire protocols cannot desync on CAS semantics. See that file's header comment for its own versioning contract.

Protocol-version integer is 1 (PROTOCOL_VERSION_1) — the only value mkit currently speaks. Wire-breaking changes (renumbering or removing a field) are made by introducing a sibling signer2.proto / ssh2.proto package and bumping to a new ProtocolVersion value, never by silently reinterpreting PROTOCOL_VERSION_1's existing wire shape (see §2).

Distribution. These schemas are published as the buf.build/officialunofficial/mkit-rpc module on the Buf Schema Registry on every tagged release (the named module at rust/crates/mkit-rpc/proto in the repo-root buf.yaml workspace, pushed by the buf-push job in .github/workflows/crates-publish.yml; see docs/RELEASE.md). Integrators generate typed bindings in their own language with buf generate against a pinned module ref instead of vendoring this repo — the checked-in rust/crates/mkit-rpc/proto/buf.gen.yaml is the reference codegen recipe (Swift today; add other languages the same way).


1. Wire framing

Both protocols use the same length-prefixed framing:

+------------------+------------------------------------+
| u32 LE length    | N bytes protobuf-encoded Frame     |
+------------------+------------------------------------+
  • Length prefix: 4 bytes, little-endian unsigned integer, encoded length of the protobuf body in bytes — does NOT include the prefix itself.
  • Body: a single protobuf-encoded SignerFrame (signer protocol) or SshFrame (SSH protocol), per the oneof body { ... } definitions in the .proto files. The Rust reference implementations use the buffa runtime, but any protobuf 3/edition 2023 toolchain emitting the same wire bytes is conformant.
  • Cap: MAX_FRAME_BYTES = 1 MiB (1024 * 1024, exported from mkit-rpc as a constant of the same name). Receivers MUST close the connection on a frame whose advertised length exceeds the cap, before reading the body. Senders MUST refuse to emit a frame whose body would exceed the cap. Bulk data flows that need more than 1 MiB use a streaming pattern (see PackChunk in ssh.proto).

Receivers MUST validate the length prefix against the cap before allocating buffers — there is no streaming decode of a single frame.

A truncated length prefix or body is a connection-fatal protocol error. There is no recovery; the receiving side MUST close the underlying stream.


2. Versioning

The single ProtocolVersion enum in common.proto carries the wire contract version. Every initiating frame (SignerFrame::Hello, SshFrame::Hello) MUST set protocol = PROTOCOL_VERSION_1.

Wire-compatible additions to the v1 schemas are allowed in v0.1.x patch releases:

  • New enum values for Algorithm, KeyForm, ErrorCode (existing consumers see them as unknown variants and fall back to ERROR_CODE_UNSPECIFIED/a no-op).
  • New optional fields on existing messages (Edition 2023 default semantics).
  • New oneof variants on SignerFrame::body and SshFrame::body (existing consumers reject them with ERROR_CODE_INVALID_REQUEST).

Renumbering or removing a field is a wire break. Do that by introducing a sibling *2.proto with a new package and bumping ProtocolVersion.


3. Common types

3.1 Algorithm

Wire-load-bearing integers; do NOT renumber. New algorithms append at the next available number.

NameWireNotes
ALGORITHM_UNSPECIFIED0Treated as "request invalid".
ALGORITHM_ED255191RFC 8032 raw signing. Default for commit/ref.
ALGORITHM_SECP256K12ECDSA over SHA-256. DSSE-compatible.
ALGORITHM_P2563ECDSA P-256/secp256r1/prime256v1 over SHA-256.
ALGORITHM_ED25519_WEBAUTHN4Ed25519 wrapped in a WebAuthn assertion (CTAP signers).
ALGORITHM_BLS12381_THRESHOLD5BLS12-381 threshold signature (variant MinSig); see docs/specs/SPEC-RELEASE-THRESHOLD.md.

3.2 KeyForm

NameWireUsed by
KEY_FORM_UNSPECIFIED0—
KEY_FORM_RAW_BYTES1mkit-sign-file (32-byte seed/scalar on disk).
KEY_FORM_PKCS8_DER2Reserved; no signer ships with v0.1.0.
KEY_FORM_OPAQUE_HANDLE3mkit-sign-tpm, mkit-sign-ctap. The bytes in key_ref are interpreted by the signer (TPM persistent handle, CTAP credentialId, …).

3.3 ErrorCode

NameWireMeaning
ERROR_CODE_UNSPECIFIED0Default/unset. Receivers MUST treat this as a protocol error (every Error frame MUST carry a known non-zero code).
ERROR_CODE_INVALID_REQUEST1Structurally bad request. Connection MAY continue.
ERROR_CODE_UNSUPPORTED_ALGORITHM2Signer doesn't speak the algorithm.
ERROR_CODE_UNSUPPORTED_KEY_FORM3Signer doesn't accept the key form.
ERROR_CODE_KEY_NOT_FOUND4Named key (handle/path) was absent.
ERROR_CODE_USER_DECLINED5User canceled (touched cancel on hardware key, refused TPM auth).
ERROR_CODE_AUTHENTICATION_REQUIRED6Signer needs PIN/biometric — see PinPrompt.
ERROR_CODE_HARDWARE_ERROR7Vendor-specific hardware fault; details carries the raw code.
ERROR_CODE_TIMEOUT8User presence check timed out.
ERROR_CODE_INTERNAL99Unmapped — message MUST be set.

Error.message is human-readable English, suitable for direct UI display. Producers SHOULD keep it under 1 KiB so receivers can render it inline; the framing layer does NOT enforce a per-field cap, only the MAX_FRAME_BYTES = 1 MiB whole-frame ceiling from §1. Error.details is opaque vendor-specific bytes (CTAP CBOR status, TPM TPM_RC, PKCS#11 CKR_*, etc.); programs MUST NOT pattern-match on its contents.


4. Conformance

A signer or server is mkit-rpc-compliant if and only if:

  1. It speaks length-prefixed protobuf frames as in §1.
  2. The first frame it sends or expects is Hello with protocol = PROTOCOL_VERSION_1.
  3. It returns a HelloResponse with the same protocol value before processing any other request.
  4. Every error response is an Error frame with a known non-zero ErrorCode and a non-empty message.
  5. It enforces MAX_FRAME_BYTES on receive AND on send.

Reference implementations (all in this repository):

The Swift contrib/signers/mkit-sign-se/ (Apple Secure Enclave) binary is a conforming v1 reference signer: it speaks the length-prefixed protobuf wire (its Package.swift depends on swift-protobuf, with generated common.pb.swift/signer.pb.swift), and is listed alongside the other reference signers in SPEC-EXTERNAL-SIGNER §8. It wires through the external signer selector like any other mkit-rpc signer.


5. Why protobuf, not JSON

mkit-rpc replaces an earlier line-JSON external-signer protocol and a hand-rolled OP_HELLO byte format on the SSH transport. The protobuf choice is deliberate:

  • Forward-compatible by default. Edition 2023 fields are explicit-presence; adding optional fields or oneof variants is a wire-compatible patch-level change.
  • Cross-language. mkit ships in Rust, but external signers may ship in any language with a protobuf 3/edition 2023 implementation. JSON parsing edge cases (number precision, escape handling, key ordering) are not in the contract.
  • Bounded sizes by construction. Length-prefixed framing with a hard cap is simpler to reason about than JSON-line streams that may need arbitrary lookahead for } matching.
  • Schema-as-source-of-truth. The .proto files are the contract; prose specs (this file, SPEC-EXTERNAL-SIGNER, SPEC-TRANSPORT) are derivative.

The Rust reference implementations currently generate their protobuf bindings with the buffa runtime — this is a reference-implementation detail, not a specification requirement, and this document does not pin a version (a version pinned in prose drifts silently from whatever the workspace actually builds against). Other languages, and other Rust implementations, can use any protobuf 3/edition 2023 toolchain that emits the wire bytes defined by the .proto files listed at the top of this document (§1); conformance is defined by those bytes, never by which codegen tool produced them.


6. Invariants

InvariantEnforced by
No frame exceeds 1 MiB, in either directionMAX_FRAME_BYTES checked on receive AND send; conformance requires both (§1, §4)
A hostile length prefix cannot force a large allocationlength validated against the cap before buffers are allocated; no streaming decode of a single frame (§1)
A truncated frame is never mis-parsed as a shorter onetruncation is connection-fatal; the receiver MUST close the stream, no recovery (§1)
No request is processed before version agreementfirst frame MUST be Hello with PROTOCOL_VERSION_1; HelloResponse echoes it before any other processing (§2, §4)
Every error is machine-actionableError frames MUST carry a known non-zero ErrorCode and non-empty message; ERROR_CODE_UNSPECIFIED is itself a protocol error (§3.3, §4)
A receiver never mistakes a missing ErrorCode for an ordinary unmapped onemkit_rpc::helpers::rpc_error_to_transport (shared by every SshFrame-based transport client) classifies code = 0/absent code as TransportError::ProtocolError, distinct from the generic RemoteError a legitimate-but-unmapped code produces (§3.3, §4)
Wire numbers never change meaningAlgorithm/KeyForm/ErrorCode integers are append-only, never renumbered; field renumbering/removal requires a sibling *2.proto + ProtocolVersion bump (§2, §3.1)
v1 additions cannot break existing peersnew enum values fall back to unknown-variant handling; new oneof variants are rejected with ERROR_CODE_INVALID_REQUEST (§2)
Bulk data never violates the frame capflows over 1 MiB use the streaming pattern (PackChunk) instead of a bigger frame (§1)
Programs never couple to vendor-opaque bytesError.details MUST NOT be pattern-matched (§3.3)

The conformance checklist in §4 is the normative test of these properties; the .proto files remain the source of truth (§5).

Files