Alle artikler

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:

Hvornår hver accessibility-indstilling lader en hemmelighed blive læst En tidslinje over en telefons dag: før første oplåsning, i brug, låst i lommen, i brug igen. Med kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly kan hemmeligheden kun læses, mens telefonen er låst op, og kun hvis der er sat en kode. Med kSecAttrAccessibleWhenUnlocked kan den kun læses, mens telefonen er låst op. På en telefon uden kode kan den første slet ikke gemmes, mens de tre andre kan læses når som helst. Med kSecAttrAccessibleAfterFirstUnlock kan den også læses, mens telefonen er låst, fra første oplåsning og frem. Med kSecAttrAccessibleAlways, som er forældet, kan den læses når som helst, selv før første oplåsning.Hvis telefonenikke har en kodeKan ikke gemmesAltid læsbarAltid læsbarAltid læsbarFør førsteoplåsningI brugLåst i lommenI brugigenLåst op, kode satkSecAttrAccessibleWhenPasscodeSetThisDeviceOnlyKun når låst opkSecAttrAccessibleWhenUnlockedEfter første oplåsningkSecAttrAccessibleAfterFirstUnlockAltid (forældet)kSecAttrAccessibleAlwaysKan læses, mens du bruger telefonenKan læses, mens du ikke bruger denFør førsteoplåsningI brugLåst ilommenI brugigenLåst op, kode satkSecAttrAccessibleWhenPasscodeSetThisDeviceOnlyKun når låst opkSecAttrAccessibleWhenUnlockedEfter første oplåsningkSecAttrAccessibleAfterFirstUnlockAltid (forældet)kSecAttrAccessibleAlwaysKan læses, mens du bruger telefonenKan læses, mens du ikke bruger denHvis telefonen ikke har en kodeLåst op, kode satKan ikke gemmesKun når låst opAltid læsbarEfter første oplåsningAltid læsbarAltid (forældet)Altid læsbar

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.

Hvem der kan åbne refresh-tokenet under hver adgangskontrol Et gitter med fem adgangskontroller mod fem typer personer eller kode. Uden adgangskontrol kan kode i appen uden Face ID, en, der kender koden, en, hvis ansigt blev tilføjet senere, en, der holder telefonen op til ejerens ansigt, og ejeren alle åbne refresh-tokenet. Med .userPresence blokeres kode i appen, men alle andre kan åbne det. Med .biometryAny kan et ansigt tilføjet senere, en, der holder telefonen op til ejeren, og ejeren åbne det. Med .biometryCurrentSet kan kun ejerens nuværende ansigt åbne det, også når en anden holder telefonen op foran ejeren. Med .biometryCurrentSet plus .applicationPassword kan kun ejeren åbne det, med sit nuværende ansigt og app-PIN-koden.Kode i appen,uden Face IDEn, der kenderkodenEn, hvis ansigtblev tilføjetsenereEn, der holdertelefonen optil dit ansigtDig, ejeren(nuværendeansigt)Ingen adgangskontrolIntet krævetKan åbneKan åbneKan åbneKan åbneKan åbne.userPresenceFace ID eller kodeBlokeretKan åbneKan åbneKan åbneKan åbne.biometryAnyEthvert registreret ansigtBlokeretBlokeretKan åbneKan åbneKan åbne.biometryCurrentSetAnsigter registreret ved lagringANBEFALETBlokeretBlokeretBlokeretKan åbneKan åbne.biometryCurrentSet +.applicationPasswordTIL APPS MED PIN-KODEBlokeretBlokeretBlokeretBlokeretKan åbneAndre end dig kan åbne detDu kan åbne detKode iappen, udenFace IDEn, derkender kodenEn, hvisansigt blevtilføjetsenereEn, derholdertelefonen optil ditansigtDig, ejeren(nuværendeansigt)Ingen adgangskontrolIntet krævet.userPresenceFace ID eller kode.biometryAnyEthvert registreret ansigt.biometryCurrentSetAnsigter registreret ved lagringANBEFALET.biometryCurrentSet + .applicationPasswordTIL APPS MED PIN-KODEAndre end dig kan åbne detDu kan åbne detBlokeret

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 · com.example.app
objection (python3.12)
~ » 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...
Det andet element er fejl 1: Kolonnen Accessible viser 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.

objection · com.example.app
objection (python3.12)
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...
Refresh-tokenet bruger "WhenUnlocked" uden "ThisDeviceOnly", så det kan gendannes på en anden telefon.

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.

Face ID tjekket af appen sammenlignet med Face ID håndhævet af telefonen Venstre: Appen viser en Face ID-prompt, får ja eller nej tilbage og læser derefter et refresh-token uden adgangskontrol. En angriber tvinger svaret til ja eller springer direkte til at læse refresh-tokenet, så alle, der kan køre kode i appen, kommer ind. Højre: Appen beder om refresh-tokenet, og telefonens sikkerhedschip tjekker Face ID og udleverer det kun ved et ægte match, så der er intet inde i appen at snyde.APPEN TJEKKER, SÅ LÆSER DEN1Face ID-prompt2Appen får "ja"/"nej"Bare en værdi i appen3Appen læser tokenetUden adgangskontrolAngriberentvinger "ja"frem, ellergår direktetil 3Alle, der kan køre kode på telefonen, kommer ind.TELEFONEN TJEKKER, SÅ UDLEVERER DEN1Appen beder om tokenetTELEFONENS SIKKERHEDSCHIP2Face ID-tjek3Token udleveretKun udleveretved et ægtematchIntet inde i appen at snyde.APPEN TJEKKER, SÅ LÆSER DEN1Face ID-prompt2Appen får "ja"/"nej"Bare en værdi i appen3Appen læser tokenetUden adgangskontrolAngriberen tvinger "ja" frem, eller går direktetil 3Alle, der kan køre kode på telefonen, kommer ind.TELEFONEN TJEKKER, SÅ UDLEVERER DEN1Appen beder om tokenetTELEFONENS SIKKERHEDSCHIP2Face ID-tjek3Token udleveretKun udleveret ved et ægte matchIntet inde i appen at snyde.

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.

objection · com.example.app
objection (python3.12)
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)
Face ID fejlede, men hooket sagde "ja" til appen.

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.

objection · com.example.app
objection (python3.12)
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...
Kolonnen ACL viser 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:

NiveauOpsætningHvad du opgiver
Mest sikkerRefresh-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 kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, 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ærkDet samme, uden DPoP.Et token kopieret af telefonen virker på en bærbar eller en anden telefon, indtil det udløber eller roteres.
MinimumRefresh-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.

Sådan binder DPoP tokens til en Secure Enclave-nøgle Telefonen har en nøgle i sin Secure Enclave, som ikke kan kopieres. Ved login sender den den offentlige nøgle, og serveren binder begge tokens til den. Ved hver forespørgsel sender telefonen tokenet med et bevis signeret af chippen, og serveren accepterer det. Angriberens bærbare med et kopieret token, men ingen nøgle, sender intet gyldigt bevis, og serveren afviser det.Telefon med appenSECURE ENCLAVENøgle, kan ikke kopieresAngriberens bærbareHar kopieret token, ingen nøgleDin server1Offentlig nøgle ved login2Token + bevis signeret af chippen3Kopieret token, intet gyldigt bevisBinder begge tokens til nøglenGodkendtAfvistTelefon med appenSECURE ENCLAVENøgle,kan ikke kopieresDin server1Offentlig nøgle ved loginBinder beggetokens til nøglen2Token + bevis signeret afchippenGodkendtAngriberens bærbareHar kopieret token,ingen nøgle3Kopieret token, intetgyldigt bevisAfvist
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

Fandt du artiklen nyttig? Del den med dit netværk.