A TLS Certificate Inside a QR Code — No CA Will Vouch for a Mac on Your Home Network
Lynsi's phone and Mac talk over TLS 1.3, but the certificate chain simply doesn't exist in this setting. The fix: put the certificate itself into the pairing QR code, and have the phone trust exactly that one from then on. Three decisions — P-256 over RSA, generating the certificate in-process instead of shelling out to openssl (and what happened in the sandbox when we didn't), and why the QR code is safe to photograph.
Lynsi is a local handoff tool I’m building between HarmonyOS phones and the Mac: the phone’s gallery, clipboard and screen appear directly on the Mac, peer-to-peer over the LAN, no servers anywhere (currently in review on both the App Store and AppGallery). With clipboard contents and photo originals riding that link, transport encryption isn’t optional — TLS 1.3.
Which immediately runs into the question every LAN-direct product runs into: who signs the certificate?
Public-key infrastructure can’t help here
TLS’s trust model rests on certificate chains: the server presents a certificate, a CA vouches for it, the client trusts the CA. But no CA will issue a certificate for “a MacBook on a home network whose IP rotates with DHCP.” It has no domain, no stable address, no verifiable identity.
So Lynsi takes the other road: a self-signed certificate, pinned. The Mac signs its own certificate; the phone receives it at pairing time and from then on trusts exactly that one, and nothing else.
Counterintuitively, this is stronger than the public CA system for this setting. Under the CA model, a compromise at any one of hundreds of CAs worldwide can mint a certificate your client will accept. Pin one known certificate and no third party can issue anything acceptable at all. The trust surface shrinks from “every CA” to “this one document.”
That leaves exactly one problem: how does the phone get the certificate safely? The pairing QR code — the certificate itself, in DER form, is encoded into the QR. Screen-to-camera is a natural out-of-band channel: being able to scan the code means you are physically standing in front of this Mac. The key-distribution problem doesn’t get solved; it disappears.
P-256, because the QR code has to scan
Once the certificate goes into a QR code, size becomes a hard constraint. An RSA certificate’s DER easily exceeds a kilobyte; EC P-256 comes out at a few hundred bytes. On screen, that’s the difference between a code that scans the moment you raise the phone and one too dense to read at all. The signature algorithm is ECDSA-SHA256, natively supported by CryptoKit and the Security framework.
Two smaller decisions are about time:
- The validity start is backdated by an hour (
notValidBefore: now - 3600). A phone whose clock runs a few seconds behind the Mac’s would otherwise reject a freshly generated certificate as “not yet valid” — first pairing fails on the spot, and nobody’s first guess is the clock. - The lifetime is ten years. Every paired phone has pinned this certificate, so expiry is not routine rotation — it is every phone dropping off simultaneously and needing a re-scan. If expiring costs a full re-enrolment, push it past the product’s lifetime.
The plaintext incident in the sandbox
The first implementation shelled out to openssl: generate a PKCS#12, import it with SecPKCS12Import. That had two costs, and the second was nearly fatal:
- The user’s machine needed a Homebrew openssl they had no way of knowing about;
- The App Sandbox forbids executing binaries outside the container. In the App Store build, openssl simply couldn’t run — no certificate materialized, and the app silently fell back to plaintext.
The second point deserves a long look. No crash, no error, everything worked — clipboard synced, files transferred — except the encryption layer had ceased to exist. This is the worst shape a security defect can take: it doesn’t break the feature, it breaks the promise, while every signal that could attract attention comes from broken features.
The fix moved the whole chain in-process:
- The X.509 certificate is generated in memory with swift-certificates and DER-serialized — no external tools touched;
SecIdentityCreatepairs the in-memory certificate with the in-memory private key directly into aSecIdentity. This is the load-bearing call: it makes both the subprocess and the keychain unnecessary. The keychain is its own source of sandbox trouble (access there depends on a signed application-identifier), and skipping it skips an entire class of build-configuration problems.
The same code now behaves identically inside and outside the sandbox, with no environment dependency — and no “degrade quietly when the environment falls short” path left to take.
The certificate must outlive the process
Self-signed certificates carry one easily-missed constraint: regenerating is revoking. Each phone pinned the old certificate; the moment the Mac swaps it, every paired phone fails to connect at once — and from either end it looks exactly like a network problem.
So the private key (key.raw, POSIX 0600) and the certificate (certificate.der) live in Application Support and are reused across launches; generation happens only on first run. An install migrating from the openssl era has a private key locked inside a PKCS#12 that can no longer be opened in-process; regeneration is the only way forward, and the log says so explicitly — “replacing pre-existing certificate: paired phones must scan again” — so a one-time migration cost looks like what it is, rather than like a connection bug.
The unit tests take the storage directory as a parameter and always point it at a temporary path. Running a test must not be capable of unpairing every one of the user’s phones — a constraint worth enforcing in the function signature rather than by carefulness.
Why the QR code is safe to photograph
One last question: besides the certificate, the QR carries a pairing token. What if someone photographs it?
The answer: the token only works for first-time enrolment. The first time a phone gets in with it, the Mac mints a credential unique to that device (keyed by deviceId) and returns it in hello.ack; from then on the phone presents its own credential and never uses the QR token again. Which means:
- Photographing the screen grants no lasting access — the picture is only useful until the token rotates, and “reset pairing code” rotates it with one click;
- Credentials are per-device, which is what makes “disconnect this device” possible: revoke one phone without disturbing the rest;
- Rotating the QR token revokes every phone, over every transport, USB included — that is exactly what “reset pairing code” is.
(How the Mac verifies these credentials, and what happens to a connection that never pairs, is the subject of another post.)
What I took away
LAN-direct products can route around key distribution instead of solving it. Screen-to-camera is a ready-made, physically-proximate out-of-band channel; hand the trust anchor (the certificate itself) across it and the entire CA/domain/ACME apparatus becomes unnecessary. The one precondition is squeezing the certificate into what a QR code can carry — which is the entire reason P-256 was chosen.
The sandbox is part of the threat model, not a deployment detail. “Depends on an external binary” is a packaging concern in a normal app; in the sandbox it is a silent failure — execution denied, degradation unvoiced. For any “fall back if unavailable” path: if the fallback end is plaintext, the path should not exist.
Comments