---
title: Hash PIN/passcode cục bộ bằng CryptoKit với salt - không lưu plaintext
impact: HIGH
impactDescription: Lưu PIN hoặc passcode dưới dạng plaintext trong Keychain vẫn bị lộ nếu device bị extract bằng jailbreak tools. Hash với salt và iteration count ngăn dictionary attack và brute force.
tags: swift, ios, password-hashing, cryptokit, pbkdf2, pin, local-auth, security
---

## Hash PIN/passcode cục bộ bằng CryptoKit với salt - không lưu plaintext

Khi lưu PIN/passcode để xác thực cục bộ (không dùng Face ID/Touch ID), phải hash bằng thuật toán có cost factor như PBKDF2 hoặc bcrypt với salt ngẫu nhiên. Không so sánh plaintext PIN. Ưu tiên dùng `LocalAuthentication.framework` với Secure Enclave thay vì tự implement.

**Incorrect (lưu PIN plaintext hoặc hash yếu):**

```swift
import Foundation
import CryptoKit

class LocalAuthManager {
    // !! Lưu PIN plaintext trong Keychain
    func savePIN(_ pin: String) throws {
        try KeychainService.saveToken(pin, key: "user_pin")  // Plaintext!
    }

    // !! MD5/SHA1 hash không có salt - rainbow table attack
    func savePINHash(_ pin: String) throws {
        let digest = Insecure.MD5.hash(data: Data(pin.utf8))
        let hash = digest.map { String(format: "%02x", $0) }.joined()
        try KeychainService.saveToken(hash, key: "user_pin_hash")  // MD5, rainbow table!
    }

    // !! So sánh timing-inequal - timing attack
    func validatePIN(_ input: String, stored: String) -> Bool {
        return input == stored  // Timing attack possible
    }
}
```

**Correct (PBKDF2 với salt ngẫu nhiên):**

```swift
import Foundation
import CryptoKit
import CommonCrypto

struct PINHasher {
    private static let saltKey = "com.app.pinSalt"
    private static let hashKey = "com.app.pinHash"
    private static let iterations: UInt32 = 100_000  // 100k iterations
    private static let keyLength = 32  // 256 bits

    // Sinh salt ngẫu nhiên và hash PIN bằng PBKDF2
    static func hashAndStorePIN(_ pin: String) throws {
        // Sinh 16-byte salt ngẫu nhiên
        var saltBytes = [UInt8](repeating: 0, count: 16)
        SecRandomCopyBytes(kSecRandomDefault, saltBytes.count, &saltBytes)
        let salt = Data(saltBytes)

        let hash = try derivePINKey(pin: pin, salt: salt)

        try KeychainService.saveToken(salt.base64EncodedString(), key: saltKey)
        try KeychainService.saveToken(hash.base64EncodedString(), key: hashKey)
    }

    // SAFE: PBKDF2-SHA256 với salt
    static func derivePINKey(pin: String, salt: Data) throws -> Data {
        guard let pinData = pin.data(using: .utf8) else {
            throw CryptoError.invalidInput
        }

        var derivedKey = [UInt8](repeating: 0, count: keyLength)
        let result = pinData.withUnsafeBytes { pinBytes in
            salt.withUnsafeBytes { saltBytes in
                CCKeyDerivationPBKDF(
                    CCPBKDFAlgorithm(kCCPBKDF2),
                    pinBytes.baseAddress, pin.count,
                    saltBytes.baseAddress, salt.count,
                    CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256),
                    iterations,
                    &derivedKey, keyLength
                )
            }
        }
        guard result == kCCSuccess else { throw CryptoError.hashFailed }
        return Data(derivedKey)
    }

    // SAFE: Constant-time comparison để tránh timing attack
    static func validatePIN(_ pin: String) throws -> Bool {
        let saltBase64 = try KeychainService.readToken(key: saltKey)
        let storedHashBase64 = try KeychainService.readToken(key: hashKey)
        guard let salt = Data(base64Encoded: saltBase64),
              let storedHash = Data(base64Encoded: storedHashBase64) else {
            throw CryptoError.invalidStoredData
        }
        let inputHash = try derivePINKey(pin: pin, salt: salt)
        // Constant-time comparison
        return inputHash.withUnsafeBytes { inputBytes in
            storedHash.withUnsafeBytes { storedBytes in
                guard inputBytes.count == storedBytes.count else { return false }
                return zip(inputBytes, storedBytes).reduce(0) { $0 | ($1.0 ^ $1.1) } == 0
            }
        }
    }
}

enum CryptoError: Error {
    case invalidInput, hashFailed, invalidStoredData
}

// BEST PRACTICE: Dùng LAContext thay vì PIN khi có thể
// import LocalAuthentication
// let context = LAContext()
// context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, ...)
```

**Tools:** CryptoKit (iOS 13+), CommonCrypto, OWASP MASVS-AUTH-3, NIST SP 800-132 (PBKDF2)
