A technical architecture reference, built by Nirapod Labs.
Abstract
The iOS and watchOS Simulators have no Secure Enclave. SimEnclave gives a simulated app a real
one by intercepting its SecKey calls and relaying the Secure Enclave operations to the
host Mac’s SEP coprocessor over an authenticated loopback channel. The simulated app
signs with genuine hardware P-256; the private key is generated inside the host SEP and
never crosses the wire. This document specifies the architecture: the interception layer,
the wire protocol, the host signer, the trust boundary, and the safety fence that makes
the tool unable to follow code into production.
Table of Contents
The problem
A hardware-backed key is defined by one property: the private scalar never leaves the secure
coprocessor. On Apple hardware that coprocessor is the Secure Enclave Processor (SEP), reached
through the SecKey C API with the token id kSecAttrTokenIDSecureEnclave. The Simulator runs
as an ordinary macOS process and exposes no SEP, so a key created with that token id fails. The
one code path you most want to exercise on every change, the hardware signing path, is exactly
the path the Simulator cannot run.
Let
The design constraint is faithfulness: the app’s bytes, error codes, and signatures must be identical to a device, so the same source runs unchanged in both places. SimEnclave adds nothing of its own to a signature and canonicalizes nothing.
System architecture
Three deployables and one shared contract span two address spaces: the simulated guest process and the host helper process. The interposer lives inside the guest; the helper owns the SEP key and serves requests over loopback.
flowchart LR
subgraph SIM["Simulator process (guest)"]
APP["App code\nSecKeyCreateSignature"]
HOOK["Interposer (C dylib)\nDobby inline hooks"]
REG["Shadow-ref registry\nSecKeyRef → handle, pubkey"]
CLI["Loopback client\nlength-prefixed CBOR"]
APP --> HOOK
HOOK --> REG
HOOK --> CLI
end
subgraph HOST["macOS host process (helper)"]
SRV["Loopback server\ntoken-authenticated"]
ROUTE["Request router"]
CORE["host-core\nSecKey C API"]
SEP["Secure Enclave (SEP)\nprivate key d stays put"]
SRV --> ROUTE --> CORE --> SEP
end
CLI ==>|"127.0.0.1 : port\nframe = len ‖ CBOR"| SRV
SEP -. "handle, Q, (r,s)" .-> CORE
CORE -. reply .-> CLIThe guest never holds a private key. What crosses the boundary is a handle, a public key
packages/interpose ©
The injected dylib. Inline-hooks the SecKey and SecItem C API, redirects SE
operations, passes everything else through.
apps/helper (Swift)
The menu bar app. Owns the SEP key, authenticates the channel, arms simulators,
answers requests.
packages/protocol
One spec, two byte-identical codecs: Swift for the helper, C for the interposer.
Trust model and the channel
The channel is a TCP socket bound to the loopback address 127.0.0.1, authenticated by a
per-session capability token. Loopback binding means only processes on the same host can
connect; the token means only a process that can read the helper’s per-user state directory can
speak the protocol.
Let the token be a uniformly random
which is negligible for any feasible
Threat boundary
In scope: another local process probing the loopback port without the token; a malformed or
hostile frame from the guest; an attacker-influenced app name or icon in a HELLO.
Out of scope: an adversary already executing as the developer’s user, who by definition owns
the SEP session and the keychain regardless of SimEnclave.
Hostile input from the guest is handled by treating every framed message as untrusted: the codec rejects duplicate keys, non-canonical integers, oversized payloads, and trailing bytes; display strings are length-clamped and stripped of control characters; an icon is validated as a real PNG under a hard byte cap before the helper renders it.
The interposer
The Secure Enclave cannot be a registered provider. A camera can be a virtual device the OS enumerates, but the SEP is reached through a reserved token id that no third party may claim. The only way in is to intercept the call inside the guest process. SimEnclave uses inline hooking: it patches the resolved function in memory so the call lands on a replacement.
For an original symbol
Inline hooking is chosen over symbol rebinding because it is independent of the symbol-binding
format, and the hook backend (Dobby) sits behind a seam so no single library is load-bearing. The
hooked surface is the set of SecKey and SecItem entry points an SE-using app touches:
| Hooked symbol | Relayed operation | Passthrough when |
|---|---|---|
SecKeyCreateRandomKey | GENERATE in the host SEP | not an SE token request |
SecKeyCreateSignature | SIGN (digest relay) | key is not SE-backed |
SecKeyCopyPublicKey | served from the shadow registry | non-SE key |
SecItemAdd / SecItemCopyMatching | tag-namespaced SE key records | non-SE query |
SecItemUpdate / SecItemDelete | re-tag / remove SE record | non-SE item |
Each hooked create returns an opaque SecKeyRef that the shadow-ref registry maps to a host
handle and the exported public key, so later calls resolve without another round trip. A key the
interposer did not mint is never claimed; the registry fails closed.
The wire protocol
The wire is length-prefixed CBOR. Each message is a 4-byte big-endian length followed by exactly
that many bytes of a canonical CBOR map. Reading is therefore deterministic: read 4, decode the
length
flowchart LR
P["length prefix\n4 bytes, big-endian"] --> B["CBOR map\nL bytes"]
B --> P2["length prefix\n4 bytes"] --> B2["CBOR map\n…"]For a payload of
bytes, with the per-message 4-byte cost the only framing overhead. The 32-bit prefix bounds a
single payload at
Maps are integer-keyed, not string-keyed, so the contract is compact and stable across the two
codecs. A HELLO, for example, carries the operation, protocol version, the calling app’s bundle
id (key 14), and its display name (key 28). Because both codecs are written to the same spec and
checked against each other, they are byte-identical oracles: a fuzz vector that one accepts the
other must accept, and bit-for-bit.
Codec parity
The Swift codec (helper) and the C codec (interposer) are not merely compatible; they encode the same logical message to the same bytes. Parity is a test invariant, not a hope.
The host signer and the Secure Enclave
The helper drives the SEP through the same SecKey C API an app would use on a device. Keys are
P-256 (NIST secp256r1): the curve
with a base point
Signing is a pure relay. The guest sends the digest
and returns the signature
Because
Within a session the helper keys live in an in-memory registry indexed by handle. A permanent
key additionally registers under a tag namespaced by the simulator UDID and the calling app’s
bundle id, so two apps on one simulator stay isolated exactly as two apps on one device do:
The end-to-end signing path
A single SecKeyCreateSignature in the guest becomes a hooked call, a framed request, a hardware
operation, and a framed reply.
sequenceDiagram
autonumber
participant App as App (guest)
participant Hook as Interposer
participant Helper as Helper (host)
participant SEP as Secure Enclave
App->>Hook: SecKeyCreateSignature(key, algo, digest)
Note over Hook: resolve SecKeyRef → handle (shadow registry)
Hook->>Helper: frame(SIGN: handle, algo, digest)
Helper->>Helper: authenticate token, decode CBOR
Helper->>SEP: sign(handle, digest)
SEP-->>Helper: (r, s), d never leaves
Helper-->>Hook: frame(signature)
Hook-->>App: CFData signatureEvery step but the hardware operation is constant work. The private key is touched only inside the
SEP, in the rightmost lane, and what returns to the guest is the same (r, s) an app receives on a
device.
The fence
The fence is the property that makes SimEnclave a tool that cannot ship. It is not a promise in prose; it is a structural fact plus a checked invariant.
Let
where DYLD_INSERT_LIBRARIES names the dylib in
Structural: it is simulator-only
Each dylib’s Mach-O platform is a Simulator platform (iOS-Simulator, watchOS-Simulator),
one slice each. dyld on a real device refuses a simulator-slice binary, so
Configural: it is debug-only
A consuming app wires DYLD_INSERT_LIBRARIES only in a Debug scheme. A release or archive
build sets it nowhere, so
For any production build
CI proves the repo side of this on every change. The static fence asserts that any scheme carrying the variable is Debug-only, that no Xcode project links the dylib into a build, and that the variable appears only in a reviewed allowlist. The helper carries the interposers because it is the tool that injects them; the release check asserts every payload is a simulator slice and fails closed on any device platform, so the only binaries the tool ships can never run anywhere but the Simulator.
flowchart TD
START([dyld asked to load the interposer]) --> Q1{Simulator-slice\nplatform?}
Q1 -- "no (device)" --> REJ1[/dyld refuses: wrong platform/]
Q1 -- yes --> Q2{DYLD_INSERT set\nby a Debug scheme?}
Q2 -- "no (release)" --> REJ2[/nothing to load/]
Q2 -- yes --> LOAD([interposer active in the Simulator])
REJ1 --> END([cannot reach production])
REJ2 --> ENDAutomatic arming
The helper arms every booted simulator the way SimCam bridges the camera: it sets the injection
variables in the simulator’s launchd environment, choosing the interposer slice that matches each
simulator’s platform, so any app launched afterward inherits it with no per-project wiring. iOS and
watchOS each have a slice; tvOS and visionOS are the same mechanism, one more slice each, and are
not built yet.
stateDiagram-v2
[*] --> Idle
Idle --> Armed: helper opens\nset DYLD_INSERT, PORT, TOKEN
Armed --> Armed: app launches → injected
Armed --> Idle: helper quits\nunset env
Armed --> [*]: simulator shuts downThe three variables it sets are the loader path, the helper port, and the capability token:
DYLD_INSERT_LIBRARIES, SIMENCLAVE_PORT, SIMENCLAVE_TOKEN. Apps already running are not
retroactively injected; the helper is opened before the app, exactly like SimCam.
A cost model
Signing through SimEnclave is the same hardware operation as on a device plus a fixed software envelope. Decompose the wall-clock cost of one signature:
xychart-beta x-axis "Cost component" y-axis "Relative units" bar [2.0, 3.0, 4.0, 20.0, 3.0] line [2.0, 3.0, 4.0, 20.0, 3.0]
Reading the bars left to right: hook dispatch, request encode, loopback transit, the SEP operation, and reply decode. The fourth bar, the hardware signature, is the term you actually came for; the rest is the price of putting it in the Simulator.
Summary
SimEnclave is a faithful redirect, not a reimplementation. It hooks the guest’s SecKey surface,
frames each Secure Enclave operation as length-prefixed CBOR, and relays it to the host SEP, where
the private scalar is generated and stays. The wire carries only what a device wire carries, the
signatures verify identically, and the interposer is a simulator-only binary wired in debug only, so
it cannot follow the code into production. The result is the one development affordance the
Simulator never had: real hardware-backed signing, where you write the code.
Colophon
SimEnclave is built by Nirapod Labs. It came out of building Nirapod, a non-custodial wallet whose security rests on keys that live in hardware and never leave it. That signing path, the one you most want to exercise on every change, is the one the Simulator could not run, so we built the tool we wanted, then opened it.
Tip
Nirapod Labs · github.com/nirapod-labs/simenclave · Apache-2.0
This document is written in Quarkdown and compiles from
docs/architecture/simenclave-architecture.qd.