Case study · Security

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
Overview

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.
Architecture

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.

Hamoon architecture: SwiftUI features backed by coordinators and repositories call a security core (CryptoUtils, CertificateUtil, KeychainManager, BiometricAuthManager) that talks to Keychain and Secure Enclave on-device, and to a certificate-authority backend over the network. FEATURES · MVVM-C Views SwiftUI · per feature ViewModels Observable state Coordinators Navigation · constructor DI Repositories Protocol-scoped, per feature SECURITY CORE CryptoUtils Keygen · sign · Keychain CertificateUtil X.509 parsing BiometricAuthManager Face ID · Touch ID gate CameraManager Remote face-scan capture CA BACKEND Auth & registration Token issuance Certificate issuance RA / CA bridge Sign-request service PDF / CMS TBS hashes hashes only
The backend only ever sees hashes to sign and signatures to verify: never the private key, and never a document with the key present at the same time.

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.

Engineering decisions

Trust, verified rather than assumed.

01

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.

02

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.

03

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.

04

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.

05

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.

Trade-offs

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.

The cost

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.

The cost

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.

The cost

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.

Technical challenges

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.

Performance & reliability

Tested where it matters most.

6
Crypto unit tests
Covering key generation, signing, and Keychain caching (the highest-consequence code in the app).
232
Swift files
A feature-per-module MVVM-C structure, each with its own coordinator, view models, and repository.
4
Signing surfaces, one primitive
CSR, acceptance form, PDF, and CMS signing all share the same sign-this-hash core.
Security

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.

Engineering journal

Eight months, one architecture.

2025 · Kickoff

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.

2025 · Build

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.

2025 · Build

Certificate issuance flow ships

Keychain management, biometric authentication, and token handling arrive together as one cohesive security layer.

2025 · Mistake / Fix

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.

2025 · Incident

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.

2026 · Refactor

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.

Lessons learned

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.
Outcome

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.