README.md
In contrib/signers/mkit-sign-se/ · At 62577fb
Preview
Text · 11.5 KiBmkit-sign-se — Apple Secure Enclave signer for mkit
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:
- P-256 only. The Secure Enclave supports no other algorithm. Any
SignRequestwith a non-P-256algorithmis answered with anErrorframe carryingERROR_CODE_UNSUPPORTED_ALGORITHM— this signer will not fall back to a software key. - Non-extractable private key.
SecureEnclave.P256.Signing.PrivateKeygenerates 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. - Optional biometric gating.
--require-biometriconkeygenbinds 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 returnsERROR_CODE_USER_DECLINED.
Wire protocol
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.)
Capabilities
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 promptResolving the key
The 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_refover the wire, interpreted as the UTF-8 bytes of the tag (KEY_FORM_OPAQUE_HANDLEpersigner.proto).
When both are set, the wire value wins. If neither is set the signer
answers with ERROR_CODE_INVALID_REQUEST.
Requirements
- macOS 12 (Monterey) or later.
- Apple Silicon, or an Intel Mac with a T2 security chip. (Check with
swift -e 'import CryptoKit; print(SecureEnclave.isAvailable)'.) - Swift 5.9+ toolchain — shipping Xcode 14+ or the standalone Command Line Tools is enough.
- For the e2e test:
opensslandpython3(both ship with macOS). - SwiftProtobuf 1.30+ is fetched automatically by SwiftPM on first build; no manual setup needed.
Build
$ swift build -c release
$ ls .build/release/mkit-sign-se
.build/release/mkit-sign-seOr via the Makefile:
$ make buildInstall
$ 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.
Usage
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>keygen
Mints 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.
sign
Implements 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.
list
Prints <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.
delete
Removes a key by tag. Idempotent is NOT the contract: deleting an
unknown tag exits non-zero with a tagNotFound message.
Wiring mkit to use it
.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 key
mkit keygen --algorithm p256 writes a raw-key file to
.mkit/signing.key — a different flow. For the SEP workflow:
- Run
mkit-sign-se keygen --tag my-attest-keyto mint the SEP key. - Note the printed
p256:<hex>keyid. - Add that keyid to your verifier's trust-root registry.
- Configure mkit with the
external_signer_*block above.
Security notes
- Non-extractable secret. The private scalar is generated inside the SEP and never leaves it. A full disk snapshot — or even a compromised kernel — cannot exfiltrate the key material; they can only ask the SEP to sign, subject to whatever access control was set at creation.
- Biometric-gated signing. With
--require-biometric, signing prompts for Touch ID/Face ID. Canceling the prompt surfaces as anERROR_CODE_USER_DECLINEDframe. - Device loss = key loss. SEP keys are bound to the physical SEP
silicon. They are not synced via iCloud Keychain. A lost/
wiped/reset device loses the key irrecoverably — you cannot back
it up. Export and record the public key (the
p256:<hex>keyid) before you rely on the signer in production, and plan a key-rotation policy against device loss. - One binary, one keychain. All keys this binary creates live
under
kSecAttrService = dev.mkit.mkit-sign-se;listshows only those. Deleting a key is reversible only if you kept the olddataRepresentationblob (this binary doesn't; it deletes the keychain item wholesale). - Installation path. Treat
attest.external_signer_pathas a code-execution sink (same class asgit 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/).
Troubleshooting
"Secure Enclave not available on this device"
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.
"keychain error: -25308 (User interaction is not allowed.)"
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.
"keychain error: -25300 (The specified item could not be found.)"
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.
Got an ERROR_CODE_USER_DECLINED from sign
You 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.
Code-signing for distribution
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 …Testing
$ 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.
Status
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.