SPEC-EXTERNAL-SIGNER.md

In docs/specs/ · At e15865e

e15865eHistory

Preview

Text · 20.5 KiB

spec: SPEC-EXTERNAL-SIGNER version: 1 status: draft audience: integrators shipping a signer for a platform mkit does not ship natively (HSM, Secure Enclave, TPM, WebAuthn, KMS)

SPEC-EXTERNAL-SIGNER — mkit external signer protocol v1

The wire contract between the mkit host process and a user-supplied external signer binary. Implementations that follow this spec are drop-in compatible with the external signer selector referenced in SPEC-ATTESTATIONS.md §6.2.

Audience: integrators shipping a signer for a platform mkit does not ship natively — Apple Secure Enclave, Ledger/Trezor, WebAuthn/CTAP, MetaMask/EVM wallet bridges, Makechain, HSMs, etc.

The wire format itself is defined in SPEC-RPC. This document covers the signer-specific aspects: process invocation, conversation flow, capability advertisement, and reference signer behaviors.


1. Scope

mkit asks an external binary to produce a DSSE signature over a specific blob (the PAE) under a specific algorithm, and the binary returns the signature plus a key identifier.

In scope:

  • Process invocation, I/O framing, lifetimes, exit-code semantics.
  • The signer.proto request/response shapes.
  • Capability discovery via Capabilities.
  • PIN/authentication round-trips.
  • Error surface, size limits, determinism expectations.

Out of scope:

  • Key generation or import. The signer is assumed to already possess or have access to the secret. Reference signers that do offer enrollment (mkit-sign-ctap enroll, mkit-sign-tpm keygen) expose it as a separate CLI subcommand outside the protocol.
  • The DSSE/in-toto envelope structure (SPEC-ATTESTATIONS).
  • Trust-roots configuration (SPEC-ATTESTATIONS §6.5).

2. Process invocation

mkit invokes the configured signer binary as a subprocess. The binary's path is resolved at config time and MUST be absolute; mkit refuses relative paths to close the $PATH resolution race.

$ /absolute/path/to/signer [argv...]

Optional argv tokens come from attest.external_signer_args in the user-scoped config, passed verbatim with no shell interpolation. mkit does NOT pass:

  • Any shell metacharacters.
  • The PAE on argv (it goes through stdin).
  • The algorithm on argv (it goes inside SignRequest).
  • Any environment variables it didn't already inherit.

The signer's stdin and stdout are pipes. stderr is also a pipe that mkit drains concurrently for the lifetime of the conversation (it is NOT inherited): a dedicated reader thread discards stderr to EOF, capturing only a bounded diagnostic prefix (1 MiB) for error reporting. This concurrent drain is mandatory — without it a signer that writes a large diagnostic to stderr before emitting its stdout response would deadlock (the signer blocks on a full stderr pipe; mkit blocks reading stdout). Signers MAY write free-form human-readable diagnostics to stderr at any time without fear of blocking.

2.1 Timeout and liveness

mkit bounds the entire signer conversation with a single wall-clock deadline covering every phase: spawn → request-write → response-read → stderr-drain → child-exit. On expiry mkit kills and reaps the child (no zombie, no orphaned hardware session) and returns a typed, phase-named timeout error so the operator can tell a hung touch-prompt (response-read) from a signer that produced a valid response but never exited (child-exit).

The default deadline is 120 seconds, chosen to be generous: hardware-backed signers routinely block on a physical touch, a PIN entry, or a biometric prompt before emitting any output. Operators who drive only fast software signers MAY tighten it via the user-scoped config key attest.external_signer_timeout_secs (an unsigned integer number of seconds; 0 means "deadline already passed" — a hard fail-fast). Like attest.external_signer_path, this key is user-scoped only and is rejected from per-repo .mkit/config: a hostile repo must not be able to set a multi-hour hang or a 0-second denial-of-service.

Signers SHOULD therefore make progress promptly once any required human gesture completes, and MUST NOT assume an unbounded wait.


3. Wire framing

Length-prefixed protobuf-encoded SignerFrame messages on stdin (mkit → signer) and stdout (signer → mkit). The Rust reference signers use the buffa runtime; any protobuf 3/edition 2023 toolchain emitting the same wire bytes is conformant. See SPEC-RPC §1 for the 4-byte LE length prefix, endianness, and the MAX_FRAME_BYTES = 1 MiB cap (frames exceeding the cap are a connection-fatal error).

Schema: rust/crates/mkit-rpc/proto/mkit/rpc/v1/signer/signer.proto.


4. Conversation flow

mkit -> signer:    Hello { protocol = PROTOCOL_VERSION_1,
                            caller_id, want_capabilities }
signer -> mkit:    HelloResponse { protocol, signer_id, capabilities }
mkit -> signer:    SignRequest { algorithm, key_form, key_ref, payload, context }
signer -> mkit:    SignResponse { signature, public_key, algorithm, key_id, ... }
                     OR
                   Error { code, message, details }

The host sends Hello { want_capabilities = true } alone and MUST wait for a HelloResponse selecting protocol v1 with compatible capabilities before sending SignRequest. Missing or unsupported protocol, missing capabilities, and incompatible algorithm/key form are fatal errors. A SignResponse before negotiation is rejected. A terminal Error may abort negotiation at any time; it never authorizes a signing operation.

Both request writes and the handshake response remain under §2.1's single whole-conversation deadline, with concurrent stderr draining. A signing request MUST NOT be sent before successful capability negotiation.

Optional PIN round-trip mid-sign:

signer -> mkit:    PinPrompt { reason, retries_remaining, wants_pin }
mkit   -> signer:  PinResponse { pin }
signer -> mkit:    SignResponse OR Error

mkit MAY send multiple SignRequests on a single connection; the file signer's reference implementation explicitly supports it. The signer MUST process them in order.


5. Capability advertisement

Capabilities in HelloResponse advertises what the signer can do:

FieldMeaning
algorithms[]Algorithms the signer can produce signatures with.
key_forms[]Key forms the signer accepts.
supports_pinTrue if the signer supports a PIN round-trip.
supports_certificate_chainTrue if SignResponse.certificate_chain is populated.
max_payload_bytesPer-request payload cap. 0 = "use the framing default" (1 MiB).
requires_user_presenceTrue if a touch/PIN/biometric is required. Advisory; mkit-cli MAY surface a "touch your YubiKey" hint.

mkit MUST verify the requested algorithm + key form against advertised capabilities before sending a SignRequest. Empty algorithm or key-form lists reject every request. The payload MUST fit the advertised cap (0 selects the framing default), and the entire encoded SignerFrame MUST also fit MAX_FRAME_BYTES; protobuf overhead counts toward the frame cap. Oversized requests MUST be rejected before any request bytes are written.


6. SignRequest/SignResponse semantics

SignRequest fieldMeaning
algorithmThe algorithm to sign under. MUST be one the signer advertised.
key_formThe form the key is provided in.
key_refIdentifies which key. Empty for raw-bytes signers (key path is configured at signer startup). For opaque-handle signers (CTAP credentialId, TPM 4-byte BE persistent handle): the wire value is preferred, but signers MAY fall back to a per-process default supplied on argv (mkit-sign-tpm --handle 0x81010001, mkit-sign-ctap --credential-id <b64url>) when key_ref is empty. An ill-formed key_ref (wrong length, undecodable) MUST be rejected with ERROR_CODE_INVALID_REQUEST rather than silently falling through to the argv default.
payloadRaw bytes to sign. For DSSE, this is the PAE per SPEC-ATTESTATIONS §2.1.
contextOptional context. CTAP signers ignore the wire field today and build the WebAuthn clientDataJSON themselves from the payload and configured origin; this field is reserved for a future signer that needs caller-supplied context.
SignResponse fieldMeaning
signatureCompact signature. 64 bytes for Ed25519/k256/p256.
public_keyPublic key bytes. 32 for Ed25519, 33 SEC1-compressed (or 65 uncompressed) for k256/p256. REQUIRED. CTAP/WebAuthn signers MUST fail closed if local credential metadata is missing; callers should enroll the credential first so the signer can return the public key that mkit verifies against the WebAuthn assertion.
algorithmEchoes the algorithm used.
key_idDSSE signatures[].keyid. Conventions used by the reference signers: blake3:<hex> for Ed25519 (BLAKE3-256 over the raw 32-byte public key), secp256k1:<hex(compressed-pubkey)>, p256:<hex(compressed-pubkey)>, webauthn:<base64url-cred-id>. Other schemes are tolerated; mkit treats key_id as opaque.
certificate_chain[]DER-encoded X.509 chain when applicable. Empty for raw-key signers and for both reference hardware signers today (no signer ships an attestation chain in v1).
webauthn{ authenticator_data, client_data_json } for CTAP signers — required when algorithm = ALGORITHM_ED25519_WEBAUTHN or when the signer is FIDO2-flavored P-256, so the verifier can reconstruct authenticator_data ‖ SHA-256(client_data_json) and check signature against that. Unset for non-WebAuthn signers.

Callers MUST treat SignResponse as untrusted. For non-WebAuthn responses, mkit rejects the response unless algorithm echoes the requested algorithm, public_key is present, and signature verifies against public_key and the requested payload. If key_id uses one of the reference canonical prefixes (blake3:, ed25519:, secp256k1:, p256:), mkit recomputes the expected identifier from public_key and rejects mismatches. Unknown key_id prefixes remain opaque and are accepted so third-party signer namespaces keep working. WebAuthn responses are validated by the WebAuthn verifier using the webauthn extension rather than this raw-payload check.

6.1 WebAuthn verifier policy

When a SignResponse carries the webauthn extension, mkit verifies the assertion with a two-layer check:

  1. Cryptographic wrapping (always enforced). type == "webauthn.get", clientDataJSON.challenge == base64url-nopad(PAE) (this is the binding that ties the ceremony to the mkit payload), authenticatorData is at least 37 bytes, and the signature verifies against authenticatorData ‖ SHA-256(clientDataJSON) under the returned P-256 public key. The public_key MUST be a valid SEC1 P-256 point. This is what the low-level helper verify_webauthn_wrapping does.
  2. Ceremony policy (configurable, layered on top). An independent set of knobs, each defaulting permissive/hardware-friendly:

    KnobDefaultChecks
    expected RP IDoffauthenticatorData[0..32] (rpIdHash) == SHA-256(rp_id)
    allowed originsoff (any)clientDataJSON.origin is in the allow-list
    require user presence (UP)offauthenticatorData flag bit 0 set
    require user verification (UV)offauthenticatorData flag bit 2 set
    allow cross-origintruereject clientDataJSON.crossOrigin == true when false
    previous sign counteroffauthenticatorData[33..37] (BE u32) >= previous; rollback rejected

    UP, UV, and the counter are independent knobs on purpose: a strict UV requirement would lock out UP-only roaming authenticators, and a strict counter would reject the many authenticators that always report signCount == 0. The permissive default accepts UP-only and signCount == 0.

All policy inputs derive from the existing authenticator_data and client_data_json fields — there is no proto change. RP-ID hash, flags, and signCount live inside authenticator_data; origin and crossOrigin live inside client_data_json.

Wiring decision. verify_webauthn_wrapping has no verify-attest consumer today — it is invoked only by the external signer's self-consistency check. mkit therefore exposes a policy-aware verify_webauthn_wrapping_with_policy(pae, wrapping, pubkey, sig, &policy) and keeps the bare verify_webauthn_wrapping as a thin, permissive delegate documented as cryptographic-only. The external signer routes its WebAuthn self-consistency check through the policy-aware variant with the signer's configured WebAuthnPolicy (default permissive). This is a ceremony-policy layer only: trust-root binding (which public key is authorized to attest) stays in DSSE envelope verification, not in this helper.


7. Errors

Per-request errors are returned as Error frames; the signer keeps running. Setup-phase failures (no key configured, bad permissions on the key file, missing argv flag) exit non-zero with a stderr message, and the signer MUST NOT emit any frame on stdout in that case — a half-formed response would desync the caller's reader.

Error code semantics: see SPEC-RPC §3.3.


8. Reference signers

Four reference signers ship in contrib/signers/ and exercise the full v1 wire surface: three in Rust (mkit-sign-file, mkit-sign-tpm, mkit-sign-ctap) and one in Swift (mkit-sign-se, using swift-protobuf). All loop on stdin, processing successive Hello / SignRequest pairs until the caller closes the stream; a clean EOF on the length prefix is treated as a graceful shutdown.

8.1 mkit-sign-file

Raw-bytes signer. Reads a 32-byte seed/scalar from disk. Unix permission bits MUST be 0600; otherwise the binary refuses to start.

$ mkit-sign-file --key /home/alice/.mkit/keys/default.key
algorithms = [ED25519, SECP256K1, P256]
key_forms  = [RAW_BYTES]
supports_pin = false
supports_certificate_chain = false
requires_user_presence = false

Reference/development implementation. Not production-grade — the secret is unencrypted on disk.

8.2 mkit-sign-ctap

FIDO2/WebAuthn signer. Drives a plugged-in roaming authenticator (YubiKey, Nitrokey, SoloKey, …) over CTAP-HID.

$ mkit-sign-ctap enroll --rp-id mkit.local --user-name alice
$ mkit-sign-ctap sign  --credential-id <base64url> [--pin <pin>]
algorithms = [P256]
key_forms  = [OPAQUE_HANDLE]            # the credential_id
supports_pin = true
supports_certificate_chain = false
requires_user_presence = true

The reference CTAP signer currently supports P-256 credentials only. SignResponse.public_key is required, and SignResponse.webauthn is populated with authenticator_data + client_data_json so the verifier reconstructs the signed input. A CTAP signer that has a credential ID but no local public-key metadata MUST return an error rather than an empty public_key.

--pin on argv is deprecated. It is readable by any other local user on the host via ps / /proc/<pid>/cmdline — the same exposure class docs/THREAT-MODEL.md §3.2 defends key-file confidentiality against — so it MUST NOT be relied on. Omit it: when the authenticator reports it needs a PIN, the reference signer requests one in-band over the PinPrompt/PinResponse round trip (§4) instead. On the host side, mkit-attest's ExternalSigner answers a PinPrompt via its configured PinProvider, which defaults to an interactive terminal prompt and never sources a PIN from argv or an environment variable. The reference signer prints a deprecation warning when --pin is passed.

For compatibility with mkit's current external-signer host path, the reference CTAP signer accepts KEY_FORM_RAW_BYTES only when key_ref is empty and the credential handle is supplied by argv --credential-id. Explicit credential handles should use KEY_FORM_OPAQUE_HANDLE.

8.3 mkit-sign-tpm

TPM 2.0 P-256 signer. Linux-native via tss-esapi (gated behind the tpm2 Cargo feature).

$ mkit-sign-tpm keygen --handle 0x81010001
$ mkit-sign-tpm sign   --handle 0x81010001
algorithms = [P256]
key_forms  = [OPAQUE_HANDLE]            # 4-byte BE persistent handle
supports_pin = false
supports_certificate_chain = false
requires_user_presence = false

SignRequest.key_ref is the persistent handle as a 4-byte big-endian u32; bare-argv --handle populates a per-process default that the signer uses when key_ref is empty.


9. Determinism

The signer MUST be deterministic for a fixed (payload, key) under ECDSA-SHA-256 (RFC 6979) and Ed25519 (RFC 8032). Test fixtures pin specific signature bytes against fixed scalars; non-deterministic signatures break golden-vector tests.

WebAuthn signatures are NOT deterministic (authenticatorData includes a sign counter). Verifiers reconstruct via authenticator_data || SHA-256(client_data_json); the counter does not leak through to the protocol layer.


10. Security model

The signer process is in mkit's TCB. mkit verifies that the returned signature matches the returned public_key over the requested payload, and rejects key_id mismatches for known canonical prefixes. A compromised signer can still:

  • Sign arbitrary blobs of mkit's choosing.
  • Lie about which opaque or non-canonical key_id namespace produced the signature, leading the verifier to attribute the signature to a different key if that namespace is trusted out-of-band.
  • Fail closed (error frames) but not fail open.

Mitigations the host SHOULD take:

  • Require absolute paths for the signer binary (mkit enforces).
  • Verify the signer binary's checksum out-of-band.
  • Run the signer under a restricted user/sandbox (systemd DynamicUser=, macOS sandbox-exec, …).

11. Versioning

This document and signer.proto are jointly v1. Wire-compatible additions land in v0.1.x patch releases. Breaking changes bump to v2 with a sibling signer2.proto. See SPEC-RPC §2.


12. Invariants

Properties the mkit host upholds regardless of signer behavior, plus the fail-closed rules signers MUST honor. §10 defines what a compromised signer can still do; nothing below claims otherwise.

InvariantEnforced by
The payload never appears on argv or in fresh environment variablesPAE travels only inside SignRequest.payload on stdin (§2, §6)
The signer binary cannot be swapped via $PATHabsolute path required, relative paths rejected at config time (§2)
No shell interpolation of signer argvtokens passed verbatim, no metacharacters (§2)
A hostile repo cannot choose the signer or hang the hostattest.external_signer_path / _args / _timeout_secs are user-scoped only (§2.1)
The conversation always terminates; no zombie, no orphaned hardware sessionsingle wall-clock deadline over spawn → … → child-exit; kill + reap on expiry (§2.1)
A stderr-heavy signer cannot deadlock the hostmandatory concurrent stderr drain to EOF (§2)
A signer cannot claim a signature it did not make over the requested byteshost verifies signature against the returned public_key and requested payload, and requires the algorithm echo (§6)
A signer cannot mis-bind a canonical key_id to a different keyhost recomputes the id from public_key for blake3: / ed25519: / secp256k1: / p256: prefixes and rejects mismatches (§6)
A WebAuthn assertion is bound to the mkit payloadclientDataJSON.challenge == base64url-nopad(PAE); signature checked over authenticatorData ‖ SHA-256(clientDataJSON) (§6.1)
Unsupported requests fail before signingrequested algorithm + key form checked against advertised Capabilities (§5)
An ill-formed key_ref never silently falls through to the argv defaultERROR_CODE_INVALID_REQUEST required (§6)
Missing credential metadata fails closedCTAP signers MUST error rather than return an empty public_key (§6, §8.2)
A setup failure cannot desync the framingnon-zero exit with no stdout frame (§7)
Oversized frames cannot exhaust the readerMAX_FRAME_BYTES = 1 MiB, connection-fatal (§3)
No signing request precedes compatible negotiationprotocol, capabilities and payload capacity checked before SignRequest; a terminal handshake Error aborts without signing (§4–§5)
Fixed (payload, key) gives fixed signature bytes for non-WebAuthn algorithmsRFC 6979 (ECDSA)/RFC 8032 (Ed25519) determinism, pinned by golden vectors (§9)

Files