README.md
In contrib/signers/mkit-sign-se/ · At 26cf72c
In contrib/signers/mkit-sign-se/ · At 26cf72c
mkit-sign-se is a Swift binary that implements the mkit external
signer v1 wire protocol (docs/specs/SPEC-EXTERNAL-SIGNER.md)
backed by Apple's Secure Enclave.
Headline properties:
SignRequest with a non-P-256 algorithm is answered with an
Error frame carrying ERROR_CODE_UNSUPPORTED_ALGORITHM — this
signer will not fall back to a software key.SecureEnclave.P256.Signing.PrivateKey
generates and holds the scalar inside the Secure Enclave processor;
the blob persisted to the keychain is an encrypted handle that only
the SEP on the same physical device can reconstitute.--require-biometric on keygen
binds the key to .biometryCurrentSet, so every sign prompts for
Touch ID/Face ID (and the key becomes unusable if the user enrolls
a new fingerprint/face). When the user cancels the prompt the
signer returns ERROR_CODE_USER_DECLINED.mkit-sign-se sign speaks the v1 wire — length-prefixed protobuf
SignerFrame messages on stdin/stdout. This is the same protocol
all other mkit reference signers (mkit-sign-file, mkit-sign-tpm,
mkit-sign-ctap) speak; the schema is shared
(signer.proto
plus common.proto).
The Swift implementation links the canonical Apple-platform protobuf
runtime — SwiftProtobuf,
pinned to 1.30.0+ in Package.swift. Pre-generated .pb.swift
sources are checked into Sources/mkit-sign-se/Generated/ to avoid
build-time codegen (SwiftPM has no clean equivalent of build.rs).
These are the same bindings any third-party signer integrator gets by
running buf generate against the
published buf.build/officialunofficial/mkit-rpc BSR module (issue
#719) — no vendored mkit checkout or hand-rolled protoc invocation
required. rust/crates/mkit-rpc/proto/buf.gen.yaml is the checked-in
recipe; mkit-sign-se's own copy is refreshed the same way, via
scripts/regen-mkit-sign-se-swift.sh. To regenerate after a .proto
change:
$ brew install buf # https://buf.build/docs/installation
$ ./scripts/regen-mkit-sign-se-swift.sh # from the repo root(Equivalent to running buf generate rust/crates/mkit-rpc/proto
--template rust/crates/mkit-rpc/proto/buf.gen.yaml and copying the
common/signer outputs into Generated/ — no protoc or
protoc-gen-swift install needed; buf fetches the Swift plugin as a
remote plugin execution.)
algorithms = [P256]
key_forms = [OPAQUE_HANDLE] # UTF-8 bytes of the keychain tag
supports_pin = false
supports_certificate_chain = false
requires_user_presence = true # advisory — biometric or keychain promptThe Secure Enclave key to sign with is selected by tag — the same
label keygen stored under kSecAttrAccount. The tag can be supplied
two ways:
--tag <label> on argv (per-process default).SignRequest.key_ref over the wire, interpreted as the UTF-8 bytes
of the tag (KEY_FORM_OPAQUE_HANDLE per signer.proto).When both are set, the wire value wins. If neither is set the signer
answers with ERROR_CODE_INVALID_REQUEST.
swift -e 'import CryptoKit; print(SecureEnclave.isAvailable)'.)openssl and python3 (both ship with macOS).$ swift build -c release
$ ls .build/release/mkit-sign-se
.build/release/mkit-sign-seOr via the Makefile:
$ make build$ make install # copies to /usr/local/bin
$ make install PREFIX=$HOME/.local # installs under ~/.local/binThe expected production install path is /usr/local/bin/mkit-sign-se;
that is the path mkit's attest.external_signer_path config key
should point at.
Subcommands:
mkit-sign-se keygen --tag <label> [--require-biometric]
mkit-sign-se sign [--tag <label>]
mkit-sign-se list
mkit-sign-se delete --tag <label>keygenMints a new SEP-backed P-256 key and stores it in the login keychain
under <label>. Prints the p256:<66-hex> keyid to stdout so you can
register it with an mkit trust-root registry.
$ mkit-sign-se keygen --tag my-attest-key
p256:033783333f45f68642c2df4d023a5e027ed0bf68f6ddd01506cdfe5b007456ca87Add --require-biometric to gate signing on Touch ID/Face ID.
signImplements the v1 external-signer wire protocol. Reads framed
SignerFrame messages from stdin and writes framed responses to
stdout:
mkit -> signer: SignerFrame{ Hello { protocol_version = V1, ... } }
signer -> mkit: SignerFrame{ HelloResponse { capabilities... } }
mkit -> signer: SignerFrame{ SignRequest { algorithm = P256, ... } }
signer -> mkit: SignerFrame{ SignResponse { signature, public_key, key_id, ... } }Each frame is a 4-byte little-endian u32 length prefix followed by the
protobuf body, capped at 1 MiB matching MAX_FRAME_BYTES in
rust/crates/mkit-rpc/src/framing.rs. The signer loops on stdin and
processes successive Hello and SignRequest pairs until the caller
closes the stream — a clean EOF on the length prefix is treated as a
graceful shutdown.
Per-request errors (no key with that tag, biometric declined,
unsupported algorithm) come back as Error frames; the binary keeps
running. Setup-phase failures (no Secure Enclave on the host, bad
argv) exit non-zero with a message on stderr and no frame on stdout.
listPrints <tag>\t<compressed-pubkey-hex> for each key this binary owns.
Useful after a keygen to sanity-check persistence, or on a clean
machine to confirm nothing is stored yet.
deleteRemoves a key by tag. Idempotent is NOT the contract: deleting an
unknown tag exits non-zero with a tagNotFound message.
.mkit/config:
[attest]
signer = "external"
external_signer_path = "/usr/local/bin/mkit-sign-se"
external_signer_args = "sign|--tag|my-attest-key"The pipe (|) is used in the args string because commas are reserved
for --additional-signer multi-sig specs. mkit splits on | and
passes each piece as a separate argv element, so the signer is
launched as mkit-sign-se sign --tag my-attest-key.
mkit keygen does NOT produce an SEP-backed keymkit keygen --algorithm p256 writes a raw-key file to
.mkit/signing.key — a different flow. For the SEP workflow:
mkit-sign-se keygen --tag my-attest-key to mint the SEP key.p256:<hex> keyid.external_signer_* block above.--require-biometric, signing
prompts for Touch ID/Face ID. Canceling the prompt surfaces as
an ERROR_CODE_USER_DECLINED frame.p256:<hex>
keyid) before you rely on the signer in production, and plan a
key-rotation policy against device loss.kSecAttrService = dev.mkit.mkit-sign-se; list shows only
those. Deleting a key is reversible only if you kept the old
dataRepresentation blob (this binary doesn't; it deletes the keychain
item wholesale).attest.external_signer_path as a
code-execution sink (same class as git config core.editor). For
any deployment where the local user account is less trusted than
the user who configured mkit, install the binary on a root-owned,
non-user-writable path (for example /usr/local/bin/).You are on an Intel Mac without a T2, or inside a sandboxed/
container environment where SecureEnclave.isAvailable returns
false. This signer refuses to fall back to a software key by design
— use mkit-sign-file (the reference signer) for dev/CI, and the
Apple signer only on hardware that has a real SEP.
The keychain is locked and the binary is running under a non-GUI session (for example SSH, LaunchDaemon, cron). Unlock your keychain, or mark the item as "always allow" the first time you use it interactively, or re-run under the same user session that created the key.
The tag you passed to sign/delete doesn't exist. Run
mkit-sign-se list to see what's there, or mkit-sign-se keygen
--tag <label> to create it.
ERROR_CODE_USER_DECLINED from signYou pressed Cancel on the Touch ID/Face ID dialog, let it time
out, or enrolled a new biometric after creating a
--require-biometric key (that invalidates the key per
.biometryCurrentSet). Re-create the key with a fresh keygen.
For Gatekeeper-approved distribution of this binary you'll need a
Developer ID signature and notarization. Neither is needed for local
development; swift build -c release produces a binary the running
user can execute directly. For a signed build:
$ codesign --force --options runtime --sign "Developer ID Application: …" \
.build/release/mkit-sign-se
$ xcrun notarytool submit .build/release/mkit-sign-se --wait …$ swift test # unit tests; SEP-roundtrip tests are XCTSkip'd
# if the SEP is unavailable
$ ./Tests/e2e.sh # end-to-end: keygen -> sign (over the protobuf
# wire) -> openssl verify -> reject path -> deleteThe e2e.sh script is self-contained: it assembles the request frames
with a small python3 helper (no protobuf dependency — it encodes the
varints by hand), pipes them through the signer, parses the response
frames, and verifies the compact signature out-of-band with openssl.
On a host without a SEP, the script prints a skip message and exits 0.
The script lives under Tests/e2e.sh rather than a separate tests/
directory so the same folder hosts both the XCTest sources and the
shell e2e — and so the path works on case-insensitive macOS
filesystems where Tests/ and tests/ are the same directory anyway.
Reference Swift implementation of the v1 external-signer wire
protocol. Companion to the Rust signers (mkit-sign-file,
mkit-sign-tpm, mkit-sign-ctap) — same protocol, different
language, different secret store.