Hamoon
A digital-signature app that moves certificate issuance and document signing onto a phone. The private key is generated on-device and never once leaves it.
- Role
- iOS Engineer & Architect
- Platform
- iOS (iPhone), iOS 15+
- Stack
- Swift · SwiftUI/UIKit · CryptoKit · swift-certificates · CoreData
- Scale
- X.509 issuance · in-person & remote (eKYC) intake
A certificate authority, in your pocket.
Hamoon lets a registered user obtain a personal X.509 digital-signature certificate remotely and use it (gated by Face ID or Touch ID) to sign documents issued through a certificate authority's backend. I built it end to end: the MVVM-C architecture, the cryptographic core, and the two identity-proofing paths that feed into certificate issuance.
The problem
Digital-signature certificates are normally issued through desktop tools, USB tokens, or an in-person visit to a registration authority. The problem was moving that entire lifecycle onto a phone: identity proofing, key generation, certificate signing, issuance, and later document signing. None of it could let the private key leave the device, and none of it could lower the bar the signature has to clear to carry real legal weight.
Constraints
- iOS 15+, iPhone only: no iPad-optimized layout.
- Device-bound key material, deliberately. Keys are generated with a passcode-required, non-iCloud-synced access policy, so a certificate never survives a device restore.
- No offline signing. Every signature, whether certificate or document, requires a network round trip first to fetch a server-issued hash before the private key is ever touched.
- Two identity-proofing tiers. Attended issuance assumes a prior in-person registration; unattended (remote) issuance requires a face-scan video and a national ID serial number, verified server-side, before a certificate is ever started.
- Fully right-to-left, Persian-localized UI. Every certificate date shown to the user renders on a Persian calendar.
MVVM-C, with a crypto core that trusts nothing by default.
Each feature owns a coordinator, view models, a repository, and views. Underneath all of them sits a small security core that generates keys, signs hashes, and independently checks the server's work.
Issuing a certificate starts with an RSA keypair generated directly in the Keychain, tagged to a server-issued order ID and gated by passcode plus biometric presence. The app submits the public key, along with either a registration password (attended) or a face-scan video and ID serial number (unattended), and gets back a certificate-signing-request hash and an acceptance-form hash to sign. Both are signed on-device, the Face ID or Touch ID prompt firing at that exact moment, and the signed results go back to the server, which issues the certificate. Before trusting it, the app independently hashes and compares the Keychain public key, a locally cached copy, and the certificate's own public key. All three sources have to agree.
Trust, verified rather than assumed.
Server-computed hash, device-only signing
Every signable artifact (CSR, acceptance form, PDF, CMS) is reduced server-side to a hash; the device's only cryptographic output is a signature over that hash. The private key and the full document content never both exist off-device at once.
Never trust a returned certificate at face value
After issuance, the app independently hash-compares the Keychain public key, a locally cached copy, and the certificate's own public key. Any mismatch marks the credential invalid, rather than assuming the server got it right.
A real X.509 library, not hand-rolled parsing
Certificate expiry and structure are read directly from a properly parsed X.509 certificate via a real parsing library, replacing an earlier ad-hoc expiration calculation that could drift from what the certificate actually encoded.
Two identity-proofing tiers, one issuance pipeline
Attended (in-person-enrolled) and unattended (remote, face-scan + ID serial) orders both feed the same server-side issuance flow. The client only needs two different intake screens; the certificate code path underneath stays the same.
Keys that can't be recovered, on purpose
Signing keys are generated with a passcode-required, device-only access policy and never sync via iCloud Keychain. A lost or restored device means re-enrollment; there's no recovery path. That limitation was chosen going in, not discovered after the fact.
What "the key never leaves" costs.
Server-computed hash signing over local document handling
Neither a private key nor a full document ever has to exist off-device and on the server at the same time.
No offline signing. Even re-signing something already downloaded needs a network round trip first, every time.
Device-only, non-recoverable keys
A stolen backup or a synced iCloud Keychain can never leak a signing key. There's nothing there to leak.
Losing the device means re-enrolling from zero. There is no recovery path, by design.
Independent key verification over trusting the server
A certificate that doesn't match the key that requested it gets caught and rejected client-side, not trusted blindly.
It's a check after the fact: it stops a bad certificate from being used, but it can't stop one from being issued in the first place.
Where a key never leaving the device gets hard.
Proving the certificate matches the key that asked for it
Because the private key never leaves the device, the client can't blindly trust what the certificate-issuance service hands back. Solved with an independent three-way hash comparison across the Keychain key, the locally cached key, and the certificate's own public key: any mismatch marks the credential invalid.
Remote identity proofing over a phone camera
The unattended issuance path has to verify a remote person to a standard that can carry legal signing weight, using nothing but the phone's own camera. The evidence it gathers is a face-scan video plus a national ID serial number.
The same signing handshake, four times
Certificate CSR, acceptance form, PDF, and CMS signing all follow the same fetch-a-hash, sign-it, submit-it pattern, but each has its own timing for when the biometric prompt should fire and its own encoding. Getting all four consistent was a recurring source of rework.
A certificate-expiration bug, caught and fixed
An early version tracked certificate expiry as a separately derived value instead of reading it from the certificate itself. That derived value could quietly drift from the truth. Replaced with parsing expiry directly out of the X.509 structure via a real certificate-parsing library.
Tested where it matters most.
The security model is the product.
- Private key extraction — answered by generating the key directly in the Keychain with a passcode-required, device-only access policy, plus a biometric presence requirement on every use. There is no code path that exports raw private-key bytes.
- Signing without the legitimate user present — answered by a Face ID/Touch ID gate enforced independently at both the app layer and the Keychain/Security-framework layer underneath it — defense in depth, not a single check.
- The server returning a certificate bound to the wrong key — answered by the independent three-way key verification described above.
- Password interception in transit — answered by encrypting the login password client-side against a pinned server public key before it's ever sent, independent of whatever the transport layer underneath provides.
- Remote identity spoofing — the unattended path requires a face-scan video and a national ID serial before a certificate is even started; the deeper liveness verification runs server-side, which is outside what this client can speak to.
The honest residual: this client is one half of a two-sided trust relationship. Everything it can verify about its own key material, it does. Liveness detection, identity-document validation, and certificate-authority policy live on the server, and that half of the system isn't this repository's to publish.
Eight months, one architecture.
Structured around MVVM-C from commit one
The project is scaffolded around a coordinator, model, repository, view-model, and view folder per feature. Every later feature would follow that same pattern.
The crypto core, tested from day one
Key generation, signing, and Keychain caching ship with their own unit tests written alongside the code, not retrofitted later.
Certificate issuance flow ships
Keychain management, biometric authentication, and token handling arrive together as one cohesive security layer.
A drifting expiration date, caught
An early version tracked certificate expiry as a separately derived value instead of reading it from the certificate itself. Replaced with real X.509 parsing the same day the bug was found.
Biometric enrollment takes several passes
Getting biometric fallback and enrollment right (including one merge conflict along the way) took a handful of iterations before the flow felt solid.
Retry and recovery UI, added later
Retry and recovery for the certificate-issuance flow shipped a few months after the happy path had already been live. Resilience work like that usually comes second, honestly.
What signing software teaches you to distrust.
- Build the core signing primitive once, early. Every later signing surface, CSR, acceptance form, PDF, CMS, reused the same "sign this hash" function instead of inventing cryptography per feature.
- Verify what the server hands back; don't just trust it. The three-way key check exists because a certificate is only as trustworthy as the client's willingness to double-check it.
- Retry and recovery UI is easy to defer and expensive to defer too long. It shipped months after the happy path, once real usage made the gap obvious.
A signature you can actually trust.
Hamoon's certificate-issuance and document-signing flow is built end to end: two identity-proofing paths feed one issuance pipeline, keys never leave the device, and every returned certificate is independently verified before it's trusted. The crypto core, the highest-consequence code in the app, is also the most tested part of it, by design.