Unity ios secure backend
Skill tea-x-random/unity-game-skills/skills/unity-ios-secure-backend
Secure Unity iOS leaderboards with Apple Game Center identity verification and App Attest checked by a Node/NestJS backend. Use for GKLocalPlayer authentication, fetchItemsForIdentityVerificationSignature, publicKeyURL/signature/salt/timestamp verification, RSA-SHA256, DCAppAttestService attestation/assertions, credentialId/keyId, certificate nonce extraction, client/server request hashes, anti-cheat score submission, replay protection, Swift/ObjC++ bridges, Unity C# networking, or NestJS verification. Diagnose GKError 15/not recognized by Game Center, request_hash_mismatch, unknown_key, gc_auth_failed, and score-submit 401 responses.From its SKILL.md
npx -y skills add tea-x-random/unity-game-skills --skill unity-ios-secure-backendAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
15.6 KB, ~3.8k tokens by cl100k_base, as published. Nobody here has run it
Unity iOS Secure Backend (Game Center identity + App Attest)
Make an online leaderboard for a Unity iOS game un-spoofable: the server only accepts a score if (a) it came from a real Apple ID (Game Center identity verification) and (b) it came from a genuine, unmodified build of your app on a real device (App Attest), and (c) the proof is bound to the exact bytes of this request. A Node/NestJS backend verifies all three. Gameplay must stay fully playable with no Game Center and no network — security is best-effort on top of a working local game, never a gate on play.
This skill is the hard-won residue of a long debugging session. Every claim below is grounded in shipped code; the byte-level crypto details live in references/crypto-details.md.
Doctrine (these override convenience)
- The chain runs strictly in sequence, so an early failure masks every later bug. Order on the server is: parse GC headers → verify GC identity (401 stops here) → compute request hash → App Attest attestation/assertion → persist score. Until GC passed, the App Attest code never executed even once — so three real App Attest bugs (OID length, SPKI-vs-raw-point keyId, fabricated root CA) sat latent and surfaced one at a time only as each prior gate opened. Expect this. Fix the first failing stage, redeploy, and the next latent bug appears. Do not assume "the rest works" because it compiled.
- Make every stage report its EXACT reason, end to end. The server returns a typed machine code (
gc_auth_failed,request_hash_mismatch,unknown_key, …) plus diagnostic detail in the message (e.g. the server-computedclientDataHash, whether the cert nonce was found, candidate count), and the client surfaces it on-device (a HUD line, the win card). An opaque "401" or "couldn't submit" is useless across a device round-trip you can't attach a debugger to. The client also logsLastClientHash(the base64 hash it signed) so you can diff it against the server's reportedcdh. - NEVER add a production bypass for App Attest. No "if attest fails, submit anyway" path. If App Attest is unsupported/unavailable/failed, the client does NOT submit and shows a non-blocking message; the local leaderboard is untouched. A bypass is a cheat hole.
- Security never blocks gameplay. Local-first: store the score locally, then fire-and-forget the remote POST. No GC / no network / declined sign-in → outcome is
NotSignedIn/AttestUnavailable/NetworkError, all non-errors that the player never feels. In the Editor and non-iOS, every native call resolves to a clean "not available" so the game runs. - Client and server must hash the EXACT same bytes. The whole scheme collapses if the client signs bytes the server doesn't reproduce. Build the request body once, send those bytes, hash those bytes; on the server capture the raw body before JSON parsing and hash that. Never re-serialize a parsed DTO to hash it — key order/whitespace/unicode normalization will differ.
The architecture (one POST, three proofs)
Client → POST /api/scores/submit with a JSON body and these headers:
- Game Center identity (proves a real Apple ID):
X-GC-Player-Id,X-GC-Player-Id-Alt(comma-separated candidates),X-GC-Public-Key-Url,X-GC-Signature,X-GC-Salt,X-GC-Timestamp,X-GC-Bundle-Id. - App Attest (proves genuine app + binds the request):
X-App-Attest-Key-Id(always) + exactly ONE ofX-App-Attest-Attestation(first use of a key) orX-App-Attest-Assertion(subsequent uses). - Body:
{"submissionId","score","mode","difficulty","puzzleId","displayName"}in a FIXED field order (it gets hashed).
Server pipeline (scores.controller.ts → scores.service.ts):
gameCenter.verify(identity)→ throws 401gc_auth_failedor returns a stableuserId.computeClientDataHash('POST', '/api/scores/submit', rawBody)from the raw captured bytes.- Attestation (registers a new key) or assertion (verifies an existing key) against that hash.
- Only then persist the score (idempotent on
(userId, submissionId)).
Read access (GET /api/scores/top) is public — no GC / no App Attest. Anyone reads the board; only writes are gated.
Native layer (iOS) — the bridge shape
Three files under Plugins/iOS/: a Swift Game Center class, a Swift App Attest class, and an ObjC++ .mm shim. The pattern (use it for any Unity↔Swift async callback):
- Swift exposes
@_cdecl("__name")C entry points. The.mmshim forward-declares thoseextern "C"symbols and wraps them in stable plugin-ownedGame*functions — do NOT#importthe Unity-generated-Swift.humbrella (its name varies per build target and breaks the shim); resolve at link time instead. - C# calls them via
[DllImport("__Internal")], guarded#if UNITY_IOS && !UNITY_EDITOR. The Editor/non-iOS branch returns "not available" so the game runs. - Async results marshal back as
(int requestId, const char* jsonUtf8)through one registered callback; C# keeps aDictionary<int, continuation>and dispatches byrequestId. The callback method needs[AOT.MonoPInvokeCallback(typeof(NativeCallback))]for IL2CPP. Strings from nativestrdupare freed on the C# side.
Game Center identity — gotchas
-
Architecture. At launch install
GKLocalPlayer.local.authenticateHandler(silent if signed in; iOS may present its own login UI, which the player may dismiss — never block). At submit time callfetchItems(forIdentityVerificationSignature:)→(publicKeyURL, signature, salt, timestamp). The server downloads the cert frompublicKeyURL(must be an httpsapple.comhost), then verifies an RSA-SHA256 signature overplayerId + bundleId + timestamp(big-endian uint64 MILLISECONDS) + salt. -
"This application is not recognized by Game Center" (GKError 15 / "no game matching descriptor"). The bundle id is not registered with Apple's Game Center backend. The
com.apple.developer.game-centerentitlement alone is NOT enough. FIX: in App Store Connect, the app must have Game Center enabled — in the new GC experience, create at least one Game Center component (a Leaderboard) for the app version. That registration is what makesfetchItemsstop returning "not recognized." You do NOT need to submit or get the leaderboard approved; merely creating the component registers the bundle id. -
Which player id is signed over is AMBIGUOUS, and
teamPlayerIDcan be EMPTY on some devices. This bit hard and in two ways:gamePlayerID-only → the RSA signature 401s (Apple signed over a different id).teamPlayerID-only → empty on some devices → identity looks "incomplete" → the client silently skips the whole submission (no POST at all). That silent skip is the nastiest symptom: nothing in the server logs.- SOLUTION (both sides): the client picks a guaranteed-non-empty primary (
teamPlayerID→gamePlayerID→ legacyplayerID, first non-empty) so the identity is always "complete" and the submit proceeds, AND sends all non-empty candidate ids (teamPlayerID,gamePlayerID, legacyplayerID) viaX-GC-Player-Id-Alt. The server tries each candidate as the signed id until the RSA signature verifies, and derives the stable user id from whichever verified. Do not hard-code one id — what Apple signs varies across iOS versions.
-
TestFlight uses the PRODUCTION Game Center environment (not sandbox). Sign in with the normal Apple ID, not a sandbox tester. (This pairs with
APP_ATTEST_ENV=productionbelow — both must be production for TestFlight/App Store.) -
Replay protection at the GC layer: reject stale timestamps (the verifier rejects anything older/newer than
GC_MAX_TIMESTAMP_AGE_SECONDS, default 600). The timestamp is milliseconds; it is written big-endian uint64 into the signed payload — get the unit wrong and every signature fails.
App Attest (server verification) — the latent bugs
These were ALL latent — none ran until Game Center passed (doctrine #1). They then surfaced in order:
-
The Apple App Attest Root CA must be the REAL Apple cert. A fabricated/placeholder PEM (wrong middle base64 lines) crashes startup with "bad base64 decode" the moment
new X509Certificate(...)runs underVERIFIER_MODE=real— the server never binds a port and healthchecks fail. Get the genuine cert fromapple.com/certificateauthority(Apple App Attestation Root CA). Store it as an array of lines joined with'\n', NOT a template literal — a multi-line template literal can pick up CRLF /\rfrom git or the build, embedding\rin the base64 lines → same "bad base64 decode" crash. The array-join makes it immune to line-ending mangling. -
Cert nonce extraction — OID length off-by-one (the root cause of EVERY
request_hash_mismatch). The App Attest nonce lives in a cert extension under OID1.2.840.113635.100.8.2. That OID encodes to 9 content bytes, so its DER is06 09 2a 86 48 86 f7 63 64 08 02. Searching for06 0a …(length 10) never matches → the nonce comes backnull→ the computednonce != certNoncefor every request → every submission failsrequest_hash_mismatch. FIX: search for the OID content bytes only (2a 86 48 86 f7 63 64 08 02, prefix-independent so a wrong length byte can't break it), then scan forward for the04 20(OCTET STRING, 32 bytes) marker and take the next 32 bytes as the nonce. -
keyId / credentialId = SHA256 of the UNCOMPRESSED X9.63 EC point, i.e.
0x04 || X || Y(65 bytes for P-256) — theSecKeyCopyExternalRepresentationform, NOT the SPKI DER. Hashing the SPKI DER (cert.publicKey.export({format:'der',type:'spki'})) never matchescredentialId→invalid_attestation. Rebuild the raw point from the JWKx/y(Buffer.concat([0x04, x, y])) and hash that. (You still keep the SPKI DER around — it's what you store to verify later assertions.) -
Request-hash agreement (the whole scheme hinges on this). Capture the raw request body before parsing — body-parser's
verifycallback stashesreq.rawBody = Buffer.from(buf). Compute on both sides, identically:bodyHash = SHA256(rawBodyBytes) clientDataHash = SHA256( UTF8("POST\n/api/scores/submit\n") + bodyHash )App Attest nonce =
SHA256(authData || clientDataHash)must equal the cert/assertion nonce. The client passesbase64(clientDataHash)down toDCAppAttestServiceas theclientDataHash:argument — the App Attest proof is computed outside the JSON body (no circular hashing). Method + path are part of the signed prefix; keep the route string in sync on both sides. -
Attestation → assertion lifecycle + stale-key recovery. First submission per key = attestation (
generateKey()→ persist the keyId in UserDefaults →attestKey()); later ones = assertion (generateAssertion()). The client persists the keyId and usesHasKeyto choose. The trap: if that first attestation never registers server-side — e.g. an earlier GC 401 stopped the request before App Attest ran — the client keeps sending assertions for a key the server has never seen → 400unknown_key, forever. FIX: on aunknown_key(or "attestation required") response, the client clears the persisted key and re-attests once (AppAttest.ClearKey()→ retry the submit withretried=true). This self-heals devices stuck from the GC-broken era. -
APP_ATTEST_ENVmust beproductionfor TestFlight/App Store. The attestation'saaguidencodes development vs production ("appattestdevelop"vs"appattest\0…"); a mismatch against the expected environment is rejected (invalid_attestation). Pair with the production GC environment (#4). Also:expectedAppId="{APPLE_TEAM_ID}.{APP_BUNDLE_ID}"and the attestation'srpIdHashmust equalSHA256(appId).
Anti-cheat hardening already in place (don't regress these)
- Strictly-increasing sign counter. Each assertion carries a counter; the server updates it with a guarded
UPDATE … WHERE signCount < newCount, so a replayed assertion (same counter) loses the race and is rejectedreplayed_counter. Two concurrent replays can't both win. - Key ownership. A key is bound to the first user that registered it; an assertion/attestation under another user's id is rejected
key_registered_to_another_user. - Idempotent writes. Score insert is
ON CONFLICT DO NOTHINGon(userId, submissionId); a duplicate returns the original row,idempotent:true— retries are safe. - Public read leaks no userId.
GET /topreturns{rank, name, score}only;namefalls back toPlayer####(last 4 of the user id) when there's no display name.
Debugging methodology (the meta-lesson)
When a multi-stage verification chain "doesn't work":
- Find which stage actually fails — the typed error code tells you (
gc_auth_failedvsrequest_hash_mismatchvsunknown_key). Don't theorize; read the code the device got back. - Fix that stage, redeploy, retest — and expect the next latent stage to fail, because it was never exercised. Budget for several round-trips, not one fix.
- Diff the two hashes when you hit
request_hash_mismatch: the client'sLastClientHash(base64 of what it signed) vs the server's loggedcdh. Equal hashes but still a mismatch → the cert-nonce extraction is returning null (the04 20/OID bug), not a body disagreement. Unequal hashes → the bodies differ (re-serialization, an extra field, raw-body not captured). - Surface diagnostics on-device. You cannot attach a debugger to a TestFlight build mid-fetch; the win card / a HUD line carrying
LastIdentityError,LastClientHash, and the server's reply is your only window. - Never weaken security to "make it work." No App Attest bypass, no skipping the signature check. Every fix above keeps the gate intact.
Grounding files (read these to verify any claim)
Server (NestJS): server/src/game-center/real-game-center.verifier.ts, server/src/app-attest/real-app-attest.verifier.ts, server/src/scores/scores.controller.ts, server/src/scores/scores.service.ts, server/src/raw-body.ts, server/src/common/request-hash.ts, server/src/common/errors.ts.
Native iOS: Assets/<YourGame>/Plugins/iOS/GameCenter.swift, AppAttest.swift, NetBridge.mm.
Unity C#: Assets/.../Scripts/Game/Net/GameCenter.cs, AppAttest.cs, RemoteScoreClient.cs, RemoteLeaderboard.cs.
Byte-level crypto layouts (authData parsing, raw EC point, OID/nonce DER, signed payload) are in references/crypto-details.md.
Field notes & lessons
- Initial skill. Captures the GC-identity + App Attest + NestJS verification chain and the nine debugged failures: not-recognized-by-GC registration, ambiguous/empty player id (try-all-candidates), real Apple root CA (array-joined PEM), OID 9-vs-10 byte nonce extraction, keyId = SHA256(uncompressed EC point) not SPKI, raw-body request-hash agreement, attestation→assertion lifecycle + unknown_key self-heal, production env for TestFlight, and the sequential-chain-masks-later-bugs methodology.
What ships with it: 2 files
5.5 KB alongside SKILL.md
agents/
- openai.yaml216 B
references/
- crypto-details.md5.3 KB