iOS · Mobilsikkerhed
At gemme hemmeligheder i iOS Keychain er kun det halve arbejde
Introduktion
Mange iOS-apps gemmer følsomme data, som adgangskoder, nøgler og personoplysninger, i Apples Keychain, iPhonens indbyggede pengeskab til hemmeligheder. Det er bedre end at gemme dem i UserDefaults, en almindelig indstillingsfil i appens mappe, som havner i backups og kan læses med simple værktøjer. Men de indstillinger, der afgør, hvornår de kan læses, og hvem der skal være til stede, står ofte på standardværdierne eller er sat for løst.
Login-tokens er et typisk eksempel. Mange apps har et access-token (kortlivet, sendes med hver forespørgsel) og et refresh-token (langlivet, bruges til at hente nye access-tokens). Vi finder ofte begge gemt uden nogen adgangskontrol og med en accessibility-indstilling, der ikke passer til, hvordan de bruges. På en testtelefon dumper vi Keychain, kopierer refresh-tokenet og logger ind som brugeren fra en bærbar. Intet tjek af, at det er brugerens telefon, ingen advarsel, ingen kryptering brudt. Med adgangskontrol ville det samme dump have krævet ejerens ansigt. Løsningen er ofte bare et par linjer kode.
Dette indlæg forklarer, hvordan Keychain-beskyttelse virker, de seks fejl, vi oftest ser, hvad vi anbefaler i stedet, og hvordan du implementerer det, med de to tokens som eksempel hele vejen igennem.
Sådan virker Keychain-beskyttelse
Keychain krypterer hemmeligheder og holder apps adskilt, men hvornår en hemmelighed kan læses, og hvem der skal være til stede, afhænger af to indstillinger, du vælger for hver hemmelighed.
Accessibility: hvornår og hvor
Accessibility-indstillingen (kSecAttrAccessible) afgør, hvornår en hemmelighed kan læses, ud fra om telefonen er låst. Når en telefon er tændt og låst op første gang, ligger den det meste af dagen låst i en lomme:
Den strengeste mulighed, kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly (øverste række), virker som "kun når låst op", så længe telefonen har en kode. Forskellen ses på en telefon uden kode (den stiplede boks i diagrammet): en telefon uden kode tæller altid som låst op, så "kun når låst op" kan læses når som helst, mens den strengeste mulighed slet ikke kan gemmes. Hvis koden fjernes senere, slettes hemmeligheden.
De tre andre muligheder findes hver i to versioner, fx kSecAttrAccessibleWhenUnlocked og kSecAttrAccessibleWhenUnlockedThisDeviceOnly. Begge følger samme tidslinje, men ThisDeviceOnly-versionen holder hemmeligheden på denne telefon. Uden den kan en hemmelighed gendannes på en anden telefon fra en krypteret backup eller en iCloud-backup. Kode-muligheden findes kun som ThisDeviceOnly.
Adgangskontrol (ACL): hvem
Adgangskontrol (kSecAttrAccessControl), ofte kaldet ACL'en (access control list), afgør, hvem der skal være til stede. Den kan kræve Face ID, Touch ID eller telefonens kode, før hemmeligheden udleveres, og det tjekkes af telefonens sikkerhedschip i stedet for af appen. Det gælder også kode, der kører inde i appen, som en angribers hook eller vores eget Keychain-dump: Uden at bestå tjekket forbliver hemmeligheden krypteret. I stedet for eller ud over Face ID kan den kræve en PIN-kode fra appens egen PIN-skærm, som Keychain så skal bruge for at dekryptere hemmeligheden. Når du bruger en ACL, vælger du accessibility-værdien inde i den: Begge sendes med i ét kald, SecAccessControlCreateWithFlags, i stedet for at sætte kSecAttrAccessible for sig. På iPhone X og ældre modeller giver en fejl i chippens indbyggede opstartskode (checkm8), som ikke kan rettes, forensiske værktøjer dybere adgang, så beskyttelsen er svagere der.
Seks almindelige fejl
Fejl 1-3 handler om, hvornår og hvor et token kan læses, 4 og 5 om, hvem der kan læse det, og 6 om, hvem der ellers deler det, og hvor længe det gemmes. For hver af dem viser vi et eksempel, hvordan du finder fejlen, og løsningen.
For at finde dem i en kompileret app bruger vi Frida (et værktøj, der hooker sig ind i en kørende app) og Objection (et værktøjssæt bygget på Frida) på en jailbreaket testtelefon eller på et build, der er pakket om med Fridas Gadget. Et kort Frida-script kan også logge accessibility og ACL for hver hemmelighed, appen gemmer, også hemmeligheder gemt af tredjepartskode:
Frida-script: log accessibility og ACL for hver gemt hemmelighedJavaScript
// keychain-watch.js: logs a secret's accessibility and ACL every time the app saves one.
// Run: frida -U -f com.example.app -l keychain-watch.js
// Hooks SecItemAdd only. Apps that change items with SecItemUpdate need a second hook.
const security = Process.findModuleByName('Security')
?? Module.load('/System/Library/Frameworks/Security.framework/Security');
Interceptor.attach(security.getExportByName('SecItemAdd'), {
onEnter(args) {
const item = new ObjC.Object(args[0]); // the item's attributes
console.log('Saved:', item.objectForKey_('acct'), // account name
'| when:', item.objectForKey_('pdmn'), // ak = when unlocked, ck = after first unlock,
// dk = always, akpu = when passcode set.
// A "u" at the end = this device only
'| ACL:', item.objectForKey_('accc')); // access control, if any. When an item
// has an ACL, its accessibility is inside it.
}
});Fejl 1: Brug af kSecAttrAccessibleAfterFirstUnlock
Refresh-tokenet kan læses, mens telefonen er låst. Udviklere vælger ofte denne indstilling for at løse en fejl i en baggrundsopgave. Tænk på et pengeskab, der står åbent hele dagen, efter du har åbnet det første gang. Standardværdien, kSecAttrAccessibleWhenUnlocked, har ikke dette problem: Dens nøgle slettes kort efter, at telefonen låses. Dens svage punkter er fejl 2 og 3.
EksempelEn telefon bliver mistet eller stjålet og havner hos en, der har et forensisk værktøj, som dem, der sælges til politi og private laboratorier. Den er låst, men den blev låst op samme morgen, så refresh-tokenet kan stadig læses, og værktøjet trækker det ud af telefonen. Angriberen logger ind som brugeren fra en bærbar. Hver fornyelse giver dem et nyt access-token, så de beholder adgangen, indtil sessionen tilbagekaldes. Siden iOS 18.1 genstarter en telefon, der har været låst i tre dage, hvilket lukker vinduet, men det stopper ikke en angriber, der handler hurtigere.
Sådan finder du detSøg i koden efter kSecAttrAccessibleAfterFirstUnlock eller kSecAttrAccessibleAlways. I den kompilerede app viser Objections ios keychain dump hver hemmeligheds accessibility i kolonnen Accessible.
~ » objection -n com.example.app start com.example.app (run) on (iOS: 18.5) [usb] # ios keychain dump Note: You may be asked to authenticate using the devices passcode or TouchID Save the output by adding `--json keychain.json` to this command Dumping the iOS keychain... Created Accessible ACL Type Account Service Data ------------------------- -------------------------- --- -------- ------------- ---------------- ----------------------------------------- 2026-10-14 09:12:41 +0000 WhenUnlockedThisDeviceOnly Password device_id com.example.auth 7f3a9c1e 2026-10-14 09:12:41 +0000 AfterFirstUnlock Password refresh_token com.example.auth eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiI4ZjE0Z...
AfterFirstUnlock. Uden adgangskontrol (ACL) viser dumpet det uden nogen Face ID-prompt.LøsningenGem refresh-tokenet, så det kun kan læses på denne telefon, mens den er låst op, bag Face ID (se Sådan implementerer du det). Baggrundsopgaver, der kører, mens telefonen er låst, bør bruge det kortlivede access-token i stedet.
Fejl 2: Ingen kode påkrævet
Refresh-tokenet bruger en accessibility-værdi, der ikke kræver en kode, som kSecAttrAccessibleWhenUnlocked (standardværdien), i stedet for kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly. På en telefon uden kode kan tokenet så altid læses (den stiplede boks i diagrammet ovenfor). Det er som et pengeskab, hvis lås kun virker, hvis ejeren tilfældigvis har sat en kode.
EksempelEn familie-iPad har ingen kode. Alle, der tager den op, åbner din app og er logget ind som brugeren. Og fordi tokenets kryptering ikke er bundet til noget, brugeren ved, kan forensiske værktøjer kopiere det af iPad'en uden at skulle gætte en kode.
Sådan finder du detKig efter refresh-tokenet eller andre langlivede hemmeligheder gemt uden kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly. I dumpet viser kolonnen Accessible værdien. Tjek på en testtelefon uden kode, om appen stadig gemmer og læser refresh-tokenet.
LøsningenBrug kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly til refresh-tokenet. På en telefon uden kode fejler lagringen så: Bed brugeren om at sætte en kode, eller hold dem ikke logget ind.
Fejl 3: Tokens, der følger med til en ny telefon
Refresh-tokenet er ikke markeret ThisDeviceOnly eller synkroniseres til iCloud. Tænk på en husnøgle, der bliver kopieret med i hver backup.
EksempelEn bruger tager backup af sin iPhone på en delt familiecomputer. En anden med adgang til computeren og backup-adgangskoden gendanner den på sin egen iPhone. Din app åbner allerede logget ind, og hvis serveren behandler refresh-tokenet som bevis på en betroet telefon, springer den også ekstra tjek som SMS-koder over.
Sådan finder du detKig efter muligheder uden ThisDeviceOnly, eller kSecAttrSynchronizable sat til true. I dumpet ses en manglende ThisDeviceOnly i kolonnen Accessible. Dumpet viser ikke kSecAttrSynchronizable, så tjek for den i koden.
com.example.app (run) on (iOS: 18.5) [usb] # ios keychain dump Note: You may be asked to authenticate using the devices passcode or TouchID Save the output by adding `--json keychain.json` to this command Dumping the iOS keychain... Created Accessible ACL Type Account Service Data ------------------------- ------------ --- -------- ------------- ---------------- ------------------------------------------------------- 2026-10-14 09:12:41 +0000 WhenUnlocked Password refresh_token com.example.auth eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiI4ZjE0ZTQ1Zi1jZWVhLTQ...
LøsningenBrug en ThisDeviceOnly-mulighed, og synkronisér aldrig tokens.
Fejl 4: Face ID som et ja/nej-spørgsmål
Appen beder Face ID om et ja eller nej og læser derefter selv et ubeskyttet refresh-token. Det er appen, der bestemmer, ikke telefonen. Det er som en vagt, der spørger "er du ejeren?" og bare tror på svaret.
EksempelEn tyv ser ejeren taste sin kode og stjæler derefter telefonen. Det er en ældre model, som offentlige jailbreak-værktøjer understøtter, så med koden kan de jailbreake den og køre kode inde i din app. Det kræver én linje at hooke appens Face ID-tjek: Det svarer "ja", og appen går videre og læser tokenet. Havde telefonens sikkerhedschip truffet afgørelsen, ville hverken koden eller jailbreaket få tokenet ud af Keychain uden ejerens ansigt. Kode, der kører i appen, kan stadig læse tokenet fra hukommelsen efter et ægte Face ID-tjek, så hold det kun i hukommelsen, mens det er i brug.
Sådan finder du detKig efter evaluatePolicy, der beskytter et token gemt uden kSecAttrAccessControl. I den kompilerede app kører du Objections ios ui biometrics_bypass og fejler Face ID. Hvis appen stadig låser op, har den denne fejl.
com.example.app (run) on (iOS: 18.5) [usb] # ios ui biometrics_bypass (agent) Registering job NaN. Name: ios-biometrics-disable-evaluatePolicy (agent) Registering job NaN. Name: ios-biometrics-disable-evaluateAccessControl com.example.app (run) on (iOS: 18.5) [usb] # (agent) [NaN] Localized Reason for auth requirement (evaluatePolicy): Unlock your account (agent) [NaN] OS authentication response: false (agent) [NaN] Marking OS response as True instead (agent) [NaN] Biometrics bypass hook complete (evaluatePolicy)
LøsningenLæg Face ID-kravet i refresh-tokenets adgangskontrol, og fjern al "tjek, og læs så"-logik. Så opnår bypasset intet.
Fejl 5: "Ethvert ansigt" i stedet for "dette ansigt"
Refresh-tokenets adgangskontrol bruger .biometryAny eller .userPresence, så ansigter eller fingre, der tilføjes senere, også virker, og .userPresence accepterer også koden. Det er en lås, der accepterer enhver nøgle, der kommer på nøgleringen, nu eller senere.
EksempelEn kontrollerende partner, der kender koden, tilføjer et "alternativt udseende" i telefonens Face ID-indstillinger. Fra da af kan de åbne din app med deres eget ansigt, se kontoen og godkende følsomme handlinger. Ejeren får ingen advarsel.
Sådan finder du detKig efter .biometryAny eller .userPresence. I dumpet viser kolonnen ACL hver hemmeligheds adgangskontrol.
com.example.app (run) on (iOS: 18.5) [usb] # ios keychain dump Note: You may be asked to authenticate using the devices passcode or TouchID Save the output by adding `--json keychain.json` to this command Dumping the iOS keychain... Created Accessible ACL Type Account Service Data ------------------------- ----------------------------- ---------------------------- -------- ------------- ---------------- ------------ 2026-10-14 09:12:41 +0000 WhenPasscodeSetThisDeviceOnly kSecAccessControlBiometryAny Password refresh_token com.example.auth eyJhbGciOi...
kSecAccessControlBiometryAny, så et ansigt tilføjet senere kan låse refresh-tokenet op. Tokenet i kolonnen Data vises kun, fordi testeren først bestod Face ID på testtelefonen.LøsningenBrug .biometryCurrentSet til refresh-tokenet.
Fejl 6: Delt for bredt, gemt for længe
Refresh-tokenet deles med alle widgets og extensions, og det bliver liggende i Keychain, efter appen er slettet. Det er som at give alle rum en nøgle til pengeskabet og aldrig skifte låsen mellem lejere.
EksempelEn forælder sletter din app fra en gammel iPad og giver den videre til sit barn uden at nulstille den. Barnet installerer appen igen og er logget direkte ind på forælderens konto, fordi refresh-tokenet stadig lå i Keychain. Bred deling øger risikoen: Alle widgets og extensions i access-gruppen, inklusive tredjepartskode i dem, kan også læse tokenet.
Sådan finder du detTjek, hvilke hemmeligheder der sætter kSecAttrAccessGroup, og hvilke targets der deler gruppen. Hemmeligheder, der gemmes uden, havner i appens standardgruppe: den første post under Keychain Sharing i Xcodes Signing & Capabilities (entitlementet keychain-access-groups). Har appen ingen Keychain Sharing-poster, er standarden appens eget ID (team-ID plus bundle-ID), som ikke deles, medmindre du gør det med vilje. Er den første Keychain Sharing-post en gruppe, der deles med extensions, deles alle hemmeligheder, der gemmes uden en eksplicit gruppe, altså også med dem. I den kompilerede app viser codesign -d --entitlements - YourApp.app listen i rækkefølge.
LøsningenDel kun refresh-tokenet med targets, der virkelig har brug for det. Keychain-elementer overlever i dag, at appen slettes (udokumenteret adfærd, som Apple kan ændre), så slet appens tokens ved første start efter en geninstallation. Geninstallationstjekket under Opdatér eksisterende brugere gør det uden at logge brugere ud, der blot har opdateret.
Vores anbefaling
At gemme tokens i Keychain er den rigtige start, men det er kun halvdelen af arbejdet. Start med den mest sikre opsætning, og gå kun et niveau ned, hvor din trusselsmodel tillader det. Hvert trin ned opgiver noget beskyttelse:
| Niveau | Opsætning | Hvad du opgiver |
|---|---|---|
| Mest sikker | Refresh-token kun på denne telefon, kan kun læses, mens den er låst op med en kode sat, og bag ejerens nuværende Face ID. Kortlivet access-token holdt i hukommelsen eller gemt med kSecAttrAccessibleAfterFirstUnlock, hvis baggrundsopgaver har brug for det. Begge tokens bundet til telefonen med DPoP. Refresh-tokens roteres ved hver brug. | Én Face ID-prompt pr. session. Kræver DPoP-understøttelse på din server. |
| Stærk | Det samme, uden DPoP. | Et token kopieret af telefonen virker på en bærbar eller en anden telefon, indtil det udløber eller roteres. |
| Minimum | Refresh-token kun på denne telefon og kan kun læses, mens den er låst op med en kode sat, uden Face ID eller DPoP. Access-token og rotation som i Mest sikker. | Det samme som Stærk. Desuden kan alle, der holder den oplåste telefon, bruge appen som brugeren, og kode, der kører i appen, fx på en jailbreaket telefon, kan læse refresh-tokenet, mens telefonen er låst op. Passer til apps, hvor en Face ID-prompt ville give for meget friktion. |
Gå aldrig under minimum: Under det er du tilbage ved de seks fejl ovenfor. Synkronisér på alle niveauer aldrig tokens til iCloud, del dem ikke med extensions, der ikke har brug for dem, ryd dem efter en geninstallation, og hold Face ID-tjek i adgangskontrollen, aldrig i appens egen kode. Fordi alle niveauer kræver en kode, kan brugere uden kode ikke forblive logget ind (se fejl 2).
Sådan implementerer du det
Koden nedenfor følger den mest sikre opsætning fra vores anbefaling ovenfor. Til minimumsniveauet springer du SecAccessControlCreateWithFlags over og sætter kSecAttrAccessible direkte til kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly. Til det stærke niveau springer du DPoP-trinnet over.
Gem og læs refresh-tokenet
Gem refresh-tokenet med begge indstillinger. Når appen læser det, viser telefonen selv Face ID, så der er intet i appen, en angriber kan forfalske.
Gem refresh-tokenetSwift
import Security
import LocalAuthentication
enum KeychainError: Error { case unexpectedStatus(OSStatus) }
func storeRefreshToken(_ token: Data) throws {
var cfError: Unmanaged<CFError>?
guard let access = SecAccessControlCreateWithFlags(
kCFAllocatorDefault,
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, // unlocked + passcode set, never leaves this device
.biometryCurrentSet, // adding or removing a face invalidates the item
&cfError
) else { throw cfError!.takeRetainedValue() as Error }
let base: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.example.auth",
kSecAttrAccount as String: "refresh_token"
]
var old = base
old[kSecAttrSynchronizable as String] = kSecAttrSynchronizableAny
SecItemDelete(old as CFDictionary) // replace any older, weaker item. Synced copies are
// removed from the user's other devices too.
var add = base
add[kSecValueData as String] = token
add[kSecAttrAccessControl as String] = access // includes the accessibility, so no kSecAttrAccessible
add[kSecAttrSynchronizable as String] = false // never sync to iCloud
let status = SecItemAdd(add as CFDictionary, nil)
guard status == errSecSuccess else { throw KeychainError.unexpectedStatus(status) }
}Læs refresh-tokenetSwift
func readRefreshToken() throws -> Data? {
let context = LAContext()
context.localizedReason = "Unlock your account"
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.example.auth",
kSecAttrAccount as String: "refresh_token",
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecUseAuthenticationContext as String: context
]
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result) // waits for Face ID: call off the main thread
switch status {
case errSecSuccess: return result as? Data
// nil: not saved, Face ID cancelled or failed, or invalidated by a Face ID change
case errSecItemNotFound, errSecUserCanceled, errSecAuthFailed: return nil
default: throw KeychainError.unexpectedStatus(status)
}
}Læs det én gang pr. session
Hver læsning viser Face ID, så læs refresh-tokenet én gang, når appen åbner, og hold tokens i hukommelsen, mens appen er i brug. Glem dem, når appen går i baggrunden. Hvis baggrundsopgaver skal kalde serveren, så gem access-tokenet separat med kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly. Hold dets levetid nede på minutter, så eksponeringen fra fejl 1 forbliver lille. Gør aldrig dette med refresh-tokenet. Hvis access-tokenet er udløbet, venter fornyelsen, til brugeren åbner appen.
Hold tokens i hukommelsen i én sessionSwift
// Keeps the tokens in memory for one session, so Face ID is shown once.
@MainActor
final class TokenSession {
private(set) var accessToken: String?
private(set) var refreshToken: Data?
// When the app opens: one Face ID prompt, shown by readRefreshToken(). Then use
// the refresh token to get a new access token, and pass both to didRefresh.
// The read blocks until Face ID finishes, so it runs off the main thread.
// nil means signed out, a face was added or removed, or Face ID was
// cancelled or failed. Ask the user to try again or sign in.
func unlock() async throws {
refreshToken = try await Task.detached { try readRefreshToken() }.value
}
// After each refresh: keep the new tokens in memory and save the rotated
// refresh token. Saving doesn't show Face ID. Only reading does.
// If background tasks need the access token, also save it separately with
// kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly (never the refresh token).
// If saving fails, keep using the in-memory token and retry before lock():
// the old stored token is already deleted.
func didRefresh(accessToken: String, refreshToken: Data) throws {
self.accessToken = accessToken
self.refreshToken = refreshToken
try storeRefreshToken(refreshToken)
}
// When the app goes to the background, or after a few idle minutes:
// forget the tokens, so the next session needs Face ID again.
func lock() {
accessToken = nil
refreshToken = nil
}
}Håndtér ændringer i Face ID
Hvis et ansigt eller fingeraftryk tilføjes eller fjernes, eller koden fjernes, holder refresh-tokenet op med at virke. Behandl det som logget ud, og bed brugeren logge ind. For brugere uden Face ID eller Touch ID virker en kode-fallback ([.biometryCurrentSet, .or, .devicePasscode]), men så kan alle, der kender koden, bruge tokenet, så hold følsomme handlinger på Face ID alene.
Brug en app-PIN i adgangskontrollen
Nogle apps, som bankapps, beder om deres egen PIN-kode i stedet for, eller ud over, Face ID. Bind den PIN til adgangskontrollen med .applicationPassword i stedet for at tjekke den i appens egen kode. Keychain kræver så PIN-koden for at udlevere refresh-tokenet, så det hjælper ikke en angriber at forfalske appens PIN-tjek. De skal opsnappe eller gætte selve PIN-koden. Apple dokumenterer ikke en grænse for forkerte forsøg, så en kort PIN kan gættes af en angriber, der kan køre kode i appen. Brug seks cifre eller flere, og kombinér med Face ID, hvor du kan.
Bind en app-PIN til adgangskontrollenSwift
// Require the app PIN as well as the owner's current Face ID.
// (Use .applicationPassword alone for a PIN without Face ID.)
let access = SecAccessControlCreateWithFlags(
nil, kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
[.biometryCurrentSet, .and, .applicationPassword], nil)!
// Hand over the PIN from your own PIN screen, so iOS doesn't ask for it.
let context = LAContext()
_ = context.setCredential(Data(pin.utf8), type: .applicationPassword)
// Save the refresh token with both kSecAttrAccessControl: access and
// kSecUseAuthenticationContext: context, and pass a new context with the
// PIN when reading. A wrong PIN makes the read fail (typically errSecAuthFailed).Opdatér eksisterende brugere
Ny kode beskytter kun tokens, der gemmes fra nu af. Gem eksisterende refresh-tokens igen én gang, ved første start efter opdateringen, mens appen er på skærmen. Geninstallationstjekket nedenfor kører først: Det rydder hemmeligheder efterladt af en tidligere installation, men beholder dem efter en opdatering, så en opdatering ikke logger nogen ud.
GeninstallationstjekSwift
// Call once at launch, while the app is on screen, before reading any secrets.
// Keychain items survive deleting the app, but UserDefaults doesn't. Comparing the two
// tells a reinstall apart from an app update.
func prepareKeychain() {
let defaults = UserDefaults.standard
let marker: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.example.auth",
kSecAttrAccount as String: "install_marker"
]
let keychainHasMarker = SecItemCopyMatching(marker as CFDictionary, nil) == errSecSuccess
let defaultsHasMarker = defaults.bool(forKey: "installMarker")
if keychainHasMarker && !defaultsHasMarker {
// Reinstall: these secrets belong to a previous install. Clear them.
// Only items on this phone: deleting synced items would remove them everywhere.
let all: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.example.auth"
]
SecItemDelete(all as CFDictionary)
defaults.set(true, forKey: "keychainMigratedV2") // a reinstall has nothing to migrate
}
// Upgrade secrets saved by older app versions (see One-time update below). Does nothing
// once it has succeeded, or after a reinstall. Note: a first launch after upgrading
// from a version without the marker looks like an update, so a reinstall over
// such a version keeps its old secrets this once.
migrateRefreshTokenIfNeeded()
// First launch of this install (or the marker was just cleared): mark it in both places.
if !keychainHasMarker || !defaultsHasMarker {
var add = marker
add[kSecValueData as String] = Data("1".utf8)
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
SecItemAdd(add as CFDictionary, nil)
defaults.set(true, forKey: "installMarker")
}
}EngangsopdateringSwift
func migrateRefreshTokenIfNeeded() {
guard !UserDefaults.standard.bool(forKey: "keychainMigratedV2") else { return }
// The new settings need Face ID or Touch ID. Without them, saving would fail,
// so keep the old item (or save it with the passcode fallback from Handle Face ID changes).
guard LAContext().canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)
else { return }
let context = LAContext()
context.interactionNotAllowed = true // old item has no ACL, so no prompt
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.example.auth",
kSecAttrAccount as String: "refresh_token",
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny, // include iCloud-synced items
kSecReturnData as String: true,
kSecReturnAttributes as String: true,
kSecUseAuthenticationContext as String: context
]
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecSuccess,
let old = result as? [String: Any],
let token = old[kSecValueData as String] as? Data {
do {
try storeRefreshToken(token) // re-saves it with the new settings
} catch {
// Saving failed after the old item was removed: put it back as it was,
// then try again next launch.
let restore: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.example.auth",
kSecAttrAccount as String: "refresh_token",
kSecValueData as String: token,
kSecAttrAccessible as String: old[kSecAttrAccessible as String] ?? kSecAttrAccessibleWhenUnlocked,
kSecAttrSynchronizable as String: old[kSecAttrSynchronizable as String] ?? false
]
SecItemAdd(restore as CFDictionary, nil)
return
}
} else if status != errSecItemNotFound {
return // e.g. phone locked, or old item has an ACL: try again next launch
}
UserDefaults.standard.set(true, forKey: "keychainMigratedV2")
}Rotér refresh-tokens
Udsted et nyt refresh-token, hver gang et bliver brugt, og ugyldiggør det gamle. Hvis et gammelt token bruges igen, har nogen en kopi: Tilbagekald sessionen. Giv et par sekunders frist, så et netværksforsøg ikke logger en rigtig bruger ud, og gør det muligt at logge en enkelt telefon ud.
Bind tokens med DPoP
Keychain beskytter tokens, mens de er gemt, men de er stadig bearer-tokens: En kopi virker på enhver telefon eller bærbar. DPoP (Demonstrating Proof of Possession, RFC 9449) binder begge tokens til en nøgle i Secure Enclave, telefonens sikkerhedschip, som skaber nøgler, ingen kan kopiere ud. Appen signerer et kort bevis med nøglen ved hver forespørgsel, og serveren afviser ethvert token, der kommer uden et gyldigt bevis.
Opret en Secure Enclave-nøgle og signér DPoP-beviserSwift
import CryptoKit
import Foundation
enum DeviceKeyError: Error { case noSecureEnclave }
// 1. Once, at sign-in: create a key in the Secure Enclave and send its
// public half to the server. No Face ID here, because the app signs a proof
// with every request, including background ones, so it's usable after the
// first unlock. Code running in the app on this phone could still sign with it, but the
// key can't leave the chip, and without the refresh token no new access tokens are issued.
// Save key.dataRepresentation in the Keychain (AfterFirstUnlockThisDeviceOnly) to reload it.
func makeDeviceKey() throws -> SecureEnclave.P256.Signing.PrivateKey {
guard SecureEnclave.isAvailable else { throw DeviceKeyError.noSecureEnclave } // e.g. Simulator
var error: Unmanaged<CFError>?
guard let access = SecAccessControlCreateWithFlags(
nil, kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, .privateKeyUsage, &error)
else { throw error!.takeRetainedValue() as Error }
return try SecureEnclave.P256.Signing.PrivateKey(accessControl: access)
}
// 2. With every request: a DPoP proof (RFC 9449), sent in the "DPoP" header.
// Pass the access token for API calls, and nil when refreshing tokens.
func dpopProof(key: SecureEnclave.P256.Signing.PrivateKey,
method: String, url: URL, accessToken: String?) throws -> String {
var target = URLComponents(url: url, resolvingAgainstBaseURL: false)!
target.query = nil
target.fragment = nil
let pub = key.publicKey.rawRepresentation // x and y, 32 bytes each
let header: [String: Any] = [
"typ": "dpop+jwt", "alg": "ES256",
"jwk": ["kty": "EC", "crv": "P-256",
"x": b64url(pub.prefix(32)), "y": b64url(pub.suffix(32))]
]
var payload: [String: Any] = [
"jti": UUID().uuidString, // unique per proof
"htm": method, // e.g. "GET"
"htu": target.string!, // URL without query or fragment
"iat": Int(Date.now.timeIntervalSince1970) // issued at, in seconds
]
if let accessToken { // ties the proof to this access token
payload["ath"] = b64url(Data(SHA256.hash(data: Data(accessToken.utf8))))
}
let signingInput = b64url(try JSONSerialization.data(withJSONObject: header, options: .withoutEscapingSlashes)) + "." +
b64url(try JSONSerialization.data(withJSONObject: payload, options: .withoutEscapingSlashes))
let signature = try key.signature(for: Data(signingInput.utf8))
return signingInput + "." + b64url(signature.rawRepresentation)
}
func b64url(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}Nøglen kræver ikke Face ID ved hver forespørgsel, og den flytter ikke med til en ny telefon, så brugere logger ind igen der. DPoP kræver understøttelse på din OAuth-server, og hvis serveren kræver en DPoP-nonce, skal appen tilføje den til hvert bevis (koden ovenfor udelader dette).
Usikker på, hvordan din app gemmer sine tokens? Det er præcis det, vi tester. Kontakt os.
Referencer
- Apple Developer: Restricting keychain item accessibilityhttps://developer.apple.com/documentation/security/restricting-keychain-item-accessibility
- Apple Developer: SecAccessControlCreateFlagshttps://developer.apple.com/documentation/security/secaccesscontrolcreateflags
- Apple Developer: Protecting keys with the Secure Enclavehttps://developer.apple.com/documentation/security/protecting-keys-with-the-secure-enclave
- Apple Platform Security: Keychain data protectionhttps://support.apple.com/guide/security/keychain-data-protection-secb0694df1a/web
- RFC 9449: OAuth 2.0 Demonstrating Proof of Possession (DPoP)https://www.rfc-editor.org/rfc/rfc9449
- RFC 9700: Best Current Practice for OAuth 2.0 Securityhttps://www.rfc-editor.org/rfc/rfc9700
- Objection wiki: Understanding the iOS Biometrics Bypasshttps://github.com/sensepost/objection/wiki/Understanding-the-iOS-Biometrics-Bypass
- Frida-dokumentationhttps://frida.re/docs/home/
Fandt du artiklen nyttig? Del den med dit netværk.
Del på LinkedIn