Civiqapp architecture

Device attestation

Every request to the Civiq server carries proof that it comes from a genuine build on genuine hardware. The two platforms prove it very differently: iOS signs every single request; Android holds a session token.

The server splits its rejection codes so the two auth layers never collide: 403 always means device attestation failed, and 401 always means the user's account token failed. Each status has its own recovery path, and both recoveries are single-flight — a burst of concurrent failures triggers one recovery, which everyone awaits, followed by exactly one retry per request.

iOS — App Attest, with per-request assertions

Sequence diagram of the iOS App Attest ceremony and per-request assertion signing
The one-time ceremony establishes a hardware key with the server; after that, every protected request is individually signed.

The ceremony runs once per key: generate (or reuse) a hardware-backed key, fetch a server challenge from GET /device/challenge, have App Attest sign the challenge hash, and submit the attestation blob to POST /device/attest. The key ID and an attested flag persist in device_check_state.json under the application-support directory, so subsequent launches skip straight to signing.

From then on, each protected request costs an extra round trip: the service fetches a fresh challenge, builds a four-line client-data string — challenge, uppercased method, path plus query, and the SHA-256 of the body (the hash of an empty byte string for bodyless requests) — and asks App Attest for an assertion over its hash. The resulting headers are X-Platform: ios, X-Apple-App-Attest-Assertion, X-Apple-App-Attest-Key-ID, and X-Apple-App-Attest-Challenge. Binding method, path, and body into the signature is what makes a captured assertion useless for replaying against any other endpoint or payload.

INVALID_KEY recovery

Apple can invalidate a key (device restore, OS reinstall). The native side reports this as the stable platform code INVALID_KEY, and recovery is deliberately serialized: the first failing request publishes a shared recovery future, clears the persisted state, and runs one full re-attestation with a brand-new key; concurrent failers await that same future instead of racing to clear state behind each other. A joiner that wakes up to find the key ID has already changed skips re-attestation entirely and just re-signs. Each request retries at most once; a second INVALID_KEY propagates with local state wiped so the next launch starts clean.

Android — Play Integrity, with a session token

Sequence diagram of the Android Play Integrity ceremony and static token headers
One ceremony yields an android_token; requests then carry two static headers until the token expires.

Android's ceremony also starts with GET /device/challenge, encodes the challenge as unpadded base64url (matching the server's Go encoding exactly), and asks Play Integrity for a token over that nonce. POST /device/attest — this time with {"platform": "android", "integrity_token", "challenge"} — returns an android_token that persists in android_integrity_state.json. After that, every request carries the same two headers, X-Platform: android and X-Android-Device-Token, with no per-request crypto at all.

The server issues that token with roughly a 24-hour TTL. When it expires, the next protected request 403s, which triggers the recovery hook the gate registered at startup: reattest() clears the stored token and reruns the full ceremony under its own single-flight guard and a 30-second timeout, and the original request is replayed once with fresh headers. On the happy path the user sees nothing — no gate, no spinner.

How recovery is wired

Flowchart of the 401/403 recovery logic inside CiviqServerApi
One retry funnel for every verb. 403 routes to attestation recovery, 401 to token refresh; success replays the request once.

The asymmetry between platforms extends to where recovery lives. Android registers the API client's setOnAuthRejected hook, so 403 recovery happens at the transport layer. iOS registers no hook — its recovery is driven by the INVALID_KEY signal during assertion generation, inside DeviceCheckService. A consequence worth knowing: an iOS 403 caused purely server-side (say, the server lost the key) will not trigger client re-attestation; it surfaces as a normal feature-level error.

A related deliberate choice: the attestation services talk to /device/challenge and /device/attest with their own plain HTTP path rather than through CiviqServerApi — these are the two endpoints that mint attestation headers, so they must never require them.

The gate's error surface

When the initial ceremony fails, the blocking error screen maps causes to messages: status 0 (network) suggests checking the connection, 403 says the device could not be verified, 503 says the service is temporarily unavailable, a timeout suggests retrying, and unsupported hardware is fatal only in release builds. The retry button reruns the whole ceremony; a reentrancy guard makes taps during a run no-ops.

AspectiOS (App Attest)Android (Play Integrity)
Per-request cost1 extra round trip + native assertionNone (static headers)
What's signedChallenge + method + path?query + body hashNothing per-request; nonce at ceremony only
Credential lifetimeKey lives until Apple invalidates itSession token, ~24 h server TTL
Recovery triggerNative INVALID_KEY codeHTTP 403 via API-client hook
Recovery homeInside DeviceCheckServiceAttestationGate + API client
Persisted statedevice_check_state.json (keyId, isAttested)android_integrity_state.json (androidToken)