mcpField
I
ios-security

ios-security

github.com/dpearson2699セキュリティ1.2k1.1k 回インストール13 週間前に更新
リポジトリ

ios-security について

iOS 開発者向けのセキュリティガイダンススキルです。機密データ、認証、暗号化、Apple のセキュリティ要件を、実装例やレビュー項目とともに扱います。

主な機能

  1. 01Keychain への保存方法
  2. 02ファイルデータ保護クラスの説明
  3. 03CryptoKit による暗号化とハッシュ
  4. 04Secure Enclave と生体認証による保護
  5. 05ATS と証明書ピンニングの指針
  6. 06セキュアコーディングとプライバシーマニフェスト確認

活用シーン

  1. 01パスワード、トークン、API キーの安全な保存先の選定
  2. 02機密ファイルへの保護レベルの設定
  3. 03暗号化と生体認証の実装レビュー
  4. 04ネットワーク、ログ、プライバシーマニフェストの確認

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/dpearson2699/swift-ios-skills --skill ios-security

インストール後、Claude Code で/ios-securityを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

references/
app-review-guidelines.md
cryptokit-advanced.md
file-storage-patterns.md
privacy-manifest.md

詳細

iOS セキュリティ

機密データの取り扱い、ユーザー認証、適切な暗号化、iOS における Apple のセキュリティのベストプラクティスに従うためのガイダンスです。

目次

Keychain Services

Keychain は機密データを保存するための唯一の正しい場所です。パスワード、トークン、API キー、シークレットを UserDefaults、ファイル、Core Data に決して保存しないでください。

認証情報の保存
func saveToKeychain(account: String, data: Data, service: String) throws {
    let query: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrAccount as String: account,
        kSecAttrService as String: service,
        kSecValueData as String: data,
        kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
    ]

    let status = SecItemAdd(query as CFDictionary, nil)

    if status == errSecDuplicateItem {
        let updateQuery: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: account,
            kSecAttrService as String: service
        ]
        let updates: [String: Any] = [kSecValueData as String: data]
        let updateStatus = SecItemUpdate(updateQuery as CFDictionary, updates as CFDictionary)
        guard updateStatus == errSecSuccess else {
            throw KeychainError.updateFailed(updateStatus)
        }
    } else if status != errSecSuccess {
        throw KeychainError.saveFailed(status)
    }
}
認証情報の読み込み
func readFromKeychain(account: String, service: String) throws -> Data {
    let query: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrAccount as String: account,
        kSecAttrService as String: service,
        kSecReturnData as String: true,
        kSecMatchLimit as String: kSecMatchLimitOne
    ]

    var result: AnyObject?
    let status = SecItemCopyMatching(query as CFDictionary, &result)

    guard status == errSecSuccess, let data = result as? Data else {
        throw KeychainError.readFailed(status)
    }
    return data
}
認証情報の削除
func deleteFromKeychain(account: String, service: String) throws {
    let query: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrAccount as String: account,
        kSecAttrService as String: service
    ]

    let status = SecItemDelete(query as CFDictionary)
    guard status == errSecSuccess || status == errSecItemNotFound else {
        throw KeychainError.deleteFailed(status)
    }
}
kSecAttrAccessible の値
値利用可能なタイミングデバイス限定用途
kSecAttrAccessibleWhenUnlockedデバイスのロック解除中いいえ一般的な認証情報
kSecAttrAccessibleWhenUnlockedThisDeviceOnlyデバイスのロック解除中はい機密性の高い認証情報
kSecAttrAccessibleAfterFirstUnlock初回ロック解除後いいえバックグラウンドからアクセスするトークン
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly初回ロック解除後はいバックグラウンド用トークン、バックアップ対象外
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnlyパスコード設定済みかつロック解除中はい最高レベルのセキュリティ

ルール:

  • 機密データには ThisDeviceOnly バリアントを使用します。他のデバイスへのバックアップや復元を防ぎます。
  • バックグラウンド処理で必要なトークンには AfterFirstUnlock を使用します。
  • 最も機密性の高いデータには WhenPasscodeSetThisDeviceOnly を使用します。パスコードが削除されると、項目も削除されます。
  • kSecAttrAccessibleAlways は決して使用しないでください(非推奨で、安全ではありません)。
Keychain アクセスグループ

同じチームのアプリ間で Keychain 項目を共有します。

let query: [String: Any] = [
    kSecClass as String: kSecClassGenericPassword,
    kSecAttrAccount as String: "shared-token",
    kSecAttrAccessGroup as String: "TEAMID.com.company.shared"
]
@AppStorage と Keychain の比較
ストレージ用途セキュリティ
@AppStorage / UserDefaults機密性のない環境設定(テーマ、オンボーディングの状態、機能フラグ)保存時に暗号化されない
Keychainパスワード、トークン、API キー、シークレットハードウェアで暗号化され、アクセスが制御される

ルール: 公開されると困る、または危険なデータは Keychain に保存します。それ以外は @AppStorage を使用できます。

// Non-sensitive preference -- @AppStorage is fine
@AppStorage("hasCompletedOnboarding") private var hasOnboarded = false

// Sensitive credential -- MUST use Keychain
// WRONG: @AppStorage("authToken") private var token = ""
// CORRECT: Use saveToKeychain(account:data:service:)

Data Protection

iOS は保護クラスに基づいてファイルを暗号化します。

クラス利用可能なタイミング用途
.completeロック解除中のみ機密性の高いユーザーデータ
.completeUnlessOpenロック後も開いているハンドルは有効進行中のダウンロード、録音
.completeUntilFirstUserAuthentication初回ロック解除後(デフォルト)ほとんどのアプリデータ
.none常時機密性がなく、システムが必要とするデータ
// Set file protection
try data.write(to: url, options: .completeFileProtection)

// Check protection level
let attributes = try FileManager.default.attributesOfItem(atPath: path)
let protection = attributes[.protectionKey] as? FileProtectionType

ユーザーの機密データを含むファイルには .complete を使用してください。一般的なアプリデータには、デフォルトの .completeUntilFirstUserAuthentication を使用できます。

CryptoKit

すべての暗号処理には CryptoKit を使用してください。新しいコードで CommonCrypto や Security フレームワークを直接使用しないでください。

対称暗号化(AES-GCM)
import CryptoKit

let key = SymmetricKey(size: .bits256)

func encrypt(_ data: Data, using key: SymmetricKey) throws -> Data {
    let sealed = try AES.GCM.seal(data, using: key)
    guard let combined = sealed.combined else {
        throw CryptoError.sealFailed
    }
    return combined
}

func decrypt(_ data: Data, using key: SymmetricKey) throws -> Data {
    let box = try AES.GCM.SealedBox(combined: data)
    return try AES.GCM.open(box, using: key)
}
ハッシュ化
let hash = SHA256.hash(data: data)
let hashString = hash.compactMap { String(format: "%02x", $0) }.joined()

// Also available: SHA384, SHA512
HMAC(メッセージ認証)
let key = SymmetricKey(size: .bits256)

// Sign
let signature = HMAC<SHA256>.authenticationCode(for: data, using: key)

// Verify
let isValid = HMAC<SHA256>.isValidAuthenticationCode(signature, authenticating: data, using: key)

デジタル署名(P256/ECDSA)、鍵合意(Curve25519)、ChaChaPoly、HKDF による鍵導出については、references/cryptokit-advanced.md を参照してください。

Secure Enclave

最高レベルのセキュリティを実現するには、鍵を Secure Enclave に保存します。鍵がハードウェアの外に出ることはありません。対応しているのは P256 のみです。

guard SecureEnclave.isAvailable else { return }

let accessControl = SecAccessControlCreateWithFlags(
    nil, kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
    [.privateKeyUsage, .biometryCurrentSet], nil
)!
let privateKey = try SecureEnclave.P256.Signing.PrivateKey(accessControl: accessControl)

let signature = try privateKey.signature(for: data)  // May trigger biometric prompt
let isValid = privateKey.publicKey.isValidSignature(signature, for: data)

// Persist: store dataRepresentation in Keychain, restore with:
let restored = try SecureEnclave.P256.Signing.PrivateKey(
    dataRepresentation: privateKey.dataRepresentation
)

生体認証

このセクションでは、Keychain 項目とデータアクセスの生体認証による保護について説明します。ユーザー向けの生体認証サインインフロー(ログイン手段としての LAContext.evaluatePolicy)については、authentication スキルを参照してください。

LocalAuthentication(Face ID / Touch ID)
import LocalAuthentication

func authenticateWithBiometrics() async throws -> Bool {
    let context = LAContext()
    var error: NSError?

    guard context.canEvaluatePolicy(
        .deviceOwnerAuthenticationWithBiometrics, error: &error
    ) else {
        // Biometrics not available -- fall back to passcode
        if context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) {
            return try await context.evaluatePolicy(
                .deviceOwnerAuthentication,
                localizedReason: "Authenticate to access your account"
            )
        }
        throw AuthError.biometricsUnavailable
    }

    return try await context.evaluatePolicy(
        .deviceOwnerAuthenticationWithBiometrics,
        localizedReason: "Authenticate to access your account"
    )
}
Info.plist の要件

Info.plist に NSFaceIDUsageDescription を必ず含めてください。

<key>NSFaceIDUsageDescription</key>
<string>Authenticate to access your secure data</string>

このキーがないと、Face ID 搭載デバイスでクラッシュします。

LAContext の設定
let context = LAContext()
context.localizedFallbackTitle = "Use Passcode"
context.touchIDAuthenticationAllowableReuseDuration = 30
let currentState = context.evaluatedPolicyDomainState // Compare to detect enrollment changes
生体認証 + Keychain

生体認証によるアクセスで Keychain の項目を保護します。

let access = SecAccessControlCreateWithFlags(
    nil,
    kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
    .biometryCurrentSet,
    nil
)!

let query: [String: Any] = [
    kSecClass as String: kSecClassGenericPassword,
    kSecAttrAccount as String: "auth-token",
    kSecValueData as String: tokenData,
    kSecAttrAccessControl as String: access,
    kSecUseAuthenticationContext as String: LAContext()
]

SecAccessControl のフラグ:

  • .biometryCurrentSet — 生体認証を要求し、登録内容が変更されると無効になります。最も安全です。
  • .biometryAny — 生体認証を要求し、登録内容が変更されても有効です。
  • .userPresence — 生体認証またはパスコードを要求します。最も柔軟です。

App Transport Security(ATS)

ATS はデフォルトで HTTPS を強制します。無効にしないでください。

ATS の要件
  • TLS 1.2 以降
  • 前方秘匿性のある暗号スイート
  • SHA-256 以上の証明書
  • 2048 ビット以上の RSA キー(または 256 ビットの ECC)
例外ドメイン(最終手段)

<key>NSAppTransportSecurity</key>
<dict>
    <key>NSExceptionDomains</key>
    <dict>
        <key>legacy-api.example.com</key>
        <dict>
            <key>NSExceptionAllowsInsecureHTTPLoads</key>
            <true/>
            <key>NSExceptionMinimumTLSVersion</key>
            <string>TLSv1.2</string>
        </dict>
    </dict>
</dict>

ルール:

  • NSAllowsArbitraryLoads を true に設定しないでください。Apple にアプリを却下されます。
  • 例外ドメインを設定する場合は、App Review のメモで理由を説明する必要があります。
  • 例外ドメインは、自分で管理できないサードパーティのサーバーにのみ使用してください。

証明書のピンニング

機密性の高い API 接続では、MITM 攻撃を防ぐために証明書をピン留めします。

URLSession デリゲートによるピンニング
import CryptoKit

class PinnedSessionDelegate: NSObject, URLSessionDelegate {
    // SHA-256 hash of the certificate's Subject Public Key Info
    private let pinnedHashes: Set<String> = [
        "base64EncodedSHA256HashOfSPKI=="
    ]

    func urlSession(
        _ session: URLSession,
        didReceive challenge: URLAuthenticationChallenge
    ) async -> (URLSession.AuthChallengeDisposition, URLCredential?) {
        guard let trust = challenge.protectionSpace.serverTrust,
              let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate],
              let certificate = chain.first else {
            return (.cancelAuthenticationChallenge, nil)
        }

        guard let publicKey = SecCertificateCopyKey(certificate),
              let publicKeyData = SecKeyCopyExternalRepresentation(
                  publicKey, nil
              ) as Data? else {
            return (.cancelAuthenticationChallenge, nil)
        }

        let hash = SHA256.hash(data: publicKeyData)
        let hashString = Data(hash).base64EncodedString()

        if pinnedHashes.contains(hashString) {
            return (.useCredential, URLCredential(trust: trust))
        }

        return (.cancelAuthenticationChallenge, nil)
    }
}

ルール:

  • 証明書ではなく、公開鍵のハッシュをピン留めしてください。証明書は更新されますが、公開鍵はより長期間安定しています。
  • 少なくとも 1 つのバックアップピンを必ず含めてください。
  • 更新計画を立ててください。ピン留めしたキーがすべて期限切れになると、アプリは接続できなくなります。
  • キルスイッチ(緊急時にピンニングを無効化するリモート設定)の導入を検討してください。

セキュアコーディングのパターン

機密データをログに記録しない
// WRONG
logger.debug("User logged in with token: \(token)")

// CORRECT
logger.debug("User logged in successfully")
機密データをメモリから消去する
var sensitiveData = Data(/* ... */)
defer {
    sensitiveData.resetBytes(in: 0..<sensitiveData.count)
}
すべての入力を検証する
guard let url = URL(string: input),
      ["https"].contains(url.scheme?.lowercased()) else {
    throw SecurityError.invalidURL
}
let resolved = url.standardized.path
guard resolved.hasPrefix(allowedDirectory.path) else {
    throw SecurityError.pathTraversal
}
API キーのプレースホルダーパターン

プレースホルダーの API キーを誤ってコミットしないように、#error を使用してください。

// Forces a build error until the real key is configured
#error("Add your API key to Secrets.plist -- see README for setup")
private let apiKey = Secrets.value(for: "API_KEY")
脱獄の検出

既知の脱獄ファイルパス(/Applications/Cydia.app、/usr/sbin/sshd など)とサンドボックスからの脱出を確認してください。脱獄の検出は完全ではないため、唯一の対策ではなく、多層防御の一つとして使用してください。実装の全容については、references/cryptokit-advanced.md を参照してください。

プライバシーマニフェスト

アプリと SDK は、PrivacyInfo.xcprivacy でデータアクセスを宣言する必要があります。必須理由 API の宣言とセキュリティ関連のデータ収集の詳細については、 references/privacy-manifest.md を参照してください。提出要件と コンプライアンスチェックリストについては、references/app-review-guidelines.md を参照してください。

よくある間違い

  1. UserDefaults にシークレットを保存する。 トークン、パスワード、API キーは Keychain に保存する必要があります。
  2. ソースコードにシークレットをハードコードする。 Swift ファイルに API キーや認証情報を含めてはいけません。
  3. ATS を全体的に無効化する。 NSAllowsArbitraryLoads = true は却下のリスクがあります。
  4. 機密データをログに記録する。 トークン、パスワード、API キーをログに記録してはいけません。
  5. PrivacyInfo.xcprivacy がない。 必須理由 API を使用するすべてのアプリで必要です。
  6. CryptoKit ではなく CommonCrypto を使用する。 CryptoKit の方が安全で、より新しいフレームワークです。
  7. NSFaceIDUsageDescription がない。 Face ID 搭載デバイスでクラッシュします。
  8. .biometryCurrentSet が必要な場面で .biometryAny を使用する。 前者は登録内容が変更されても有効なため、セキュリティ要件の高い項目では望ましくない場合があります。
  9. パストラバーサルの脆弱性。 パスは必ず解決して検証してください。
  10. 並行処理アノテーションがない。 Keychain ラッパー型が Sendable であることを確認し、UI に関係するセキュリティプロンプトは @MainActor で分離してください。

レビューチェックリスト

  • シークレットはKeychainに保存し、UserDefaultsやファイルには保存しないこと。認証情報をハードコードしないこと
  • kSecAttrAccessibleの値が正しいこと。バックアップ対象外のデータにはThisDeviceOnlyを使用すること
  • 機密ファイルにファイル保護クラス(.complete)が設定されていること
  • 暗号化にはCryptoKitを使用すること(CommonCryptoは使用しないこと)。対称鍵は256ビットであること
  • 鍵がKeychainまたはSecure Enclaveに保存されていること
  • 生体認証にフォールバック手段があること。Info.plistにNSFaceIDUsageDescriptionがあること
  • SecAccessControlのフラグが正しいこと。LAContextが設定されていること
  • HTTPSが強制されていること。NSAllowsArbitraryLoadsがないこと。機密性の高いAPIには証明書ピンニングを使用すること
  • PrivacyInfo.xcprivacyが存在すること。必須理由APIがすべて宣言されていること
  • ログに機密データが含まれていないこと。使用後にDataがクリアされること。URL/パスが検証されていること

参考資料

コメント