If the same password opens a file on Windows but is rejected on iOS, do not rush to reset the data. The problem may lie in the password encoding, iteration count, or derived key length rather than the encryption algorithm. Run known-answer tests on a cloud Mac first, then have the iOS target call the actual application wrapper to narrow the problem down to a specific layer.
Define the Input Contract
This article is for cross-platform projects with an existing PBKDF2 protocol; it does not present PBKDF2 as the default choice for every new system. Start by documenting six fields: algorithm, PRF, password byte encoding, salt bytes, iteration count, and output length. Here, we use PBKDF2-HMAC-SHA256 with a 32-byte output.
Encode passwords as UTF-8 without automatically trimming whitespace, changing case, or applying Unicode normalization. Visually identical text can have different byte representations. If the existing protocol already defines normalization rules, keep them and include those rules in the protocol version.
Known-answer tests establish whether the computation is correct; measurements on devices establish whether the work factor is appropriate. The one- and two-iteration settings in test vectors must never be used directly in production.
Also confirm that the salt is supplied as raw bytes, not hexadecimal text. Using the string 73616c74 as the salt is not the same input as supplying the bytes of the word salt.
Run the Standard Test Vectors
On a Mac with the full development toolchain selected, save the following as pbkdf2-check.swift. It calls the system CommonCrypto library directly, so no dependencies need to be downloaded. A failure stops execution without printing passwords or derived results.
import Foundation
import CommonCrypto
enum DerivationError: Error {
case invalidInput
case failed(Int32)
}
func derive(
password: String,
salt: [UInt8],
rounds: UInt32
) throws -> [UInt8] {
guard rounds > 0, !salt.isEmpty else {
throw DerivationError.invalidInput
}
let passwordBytes = password.utf8CString
var output = [UInt8](repeating: 0, count: 32)
let status = passwordBytes.withUnsafeBufferPointer { p in
salt.withUnsafeBufferPointer { s in
output.withUnsafeMutableBufferPointer { o in
CCKeyDerivationPBKDF(
CCPBKDFAlgorithm(kCCPBKDF2),
p.baseAddress,
p.count - 1,
s.baseAddress,
s.count,
CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256),
rounds,
o.baseAddress,
o.count
)
}
}
}
guard status == kCCSuccess else {
throw DerivationError.failed(status)
}
return output
}
func hex(_ bytes: [UInt8]) -> String {
bytes.map { String(format: "%02x", $0) }.joined()
}
let salt = Array("salt".utf8)
let vectors: [(UInt32, String)] = [
(1, "120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b"),
(2, "ae4d0c95af6b46d32d0adff928f06dd02a303f8ef3c251dfd6e2d85a95474c43")
]
for (rounds, expected) in vectors {
let actual = try derive(
password: "password",
salt: salt,
rounds: rounds
)
precondition(hex(actual) == expected)
}
let full = try derive(password: "ab\u{0000}cd", salt: salt, rounds: 2)
let prefix = try derive(password: "ab", salt: salt, rounds: 2)
precondition(full != prefix)
let composed = try derive(password: "\u{00e9}", salt: salt, rounds: 2)
let decomposed = try derive(password: "e\u{0301}", salt: salt, rounds: 2)
precondition(composed != decomposed)
Run xcrun swift pbkdf2-check.swift. An exit code of zero means these assertions passed. This uses interpreted execution; do not replace it with an optimized build that disables assertions.
The code passes an explicit password byte length, excluding the trailing terminator while preserving embedded null characters. An API that determines the length by looking for a null terminator would truncate the rest of the password.
Test the Application Wrapper
Once the script passes, add the same samples to the iOS XCTest target, but call the derivation entry point the application actually uses. Do not simply copy the implementation into a test-only helper. Otherwise, the underlying algorithm may be correct while the layer above it still decodes the salt incorrectly.
Add at least these four groups of checks:
- Empty passwords: explicitly allow or reject them according to the protocol; never silently substitute another value.
- Chinese characters, emoji, and embedded null characters: verify both the input bytes and the final results on both platforms.
- Zero iterations, malformed salt encodings, and unknown algorithm identifiers: reject them before calling the derivation function.
- The same password with different salts: use fixed samples to check that the results differ and catch accidental use of a constant salt.
The example function rejects an empty salt as an input policy for this project, not because the algorithm cannot process an empty salt. Generate production salts using the system’s cryptographically secure random source. If that source fails, return an error rather than falling back to a timestamp.
Keep the Test Layers Separate
The Mac script verifies that the algorithm is wired up correctly, simulator tests verify the application wrapper, and tests on physical devices measure execution time and the impact on the UI. Record the same input contract at all three layers, but do not treat Mac performance as a proxy for phone performance.
Store Parameters for Future Upgrades
Each derivation record must store at least the format version, algorithm identifier, iteration count, salt, and output length. Where text encoding is involved, specify the format as well—for example, which Base64 rules apply to the salt. A salt is not a password; its security does not depend on keeping it secret.
When reading a record, validate field types, lengths, and the computational budget before performing the derivation. A corrupted file must not be able to supply an enormous iteration count that keeps the application’s CPU busy for a long time. Choose the work factor based on security requirements and measurements on supported devices, not by copying values from test vectors.
To upgrade parameters, do not simply overwrite global constants and then read old records. Old records must be verified using their original parameters. Only after successfully obtaining the required plaintext should you create a new-version record with a fresh salt and the new policy.
If the derived result is used for decryption, old ciphertext alone cannot be converted directly to the new parameters. Decrypt it successfully first, then encrypt it again, keeping the old record until the new one has been fully written and verified. If you store a password verifier, use a reviewed constant-time comparison implementation rather than copying the example’s ordinary array comparison.
Add Merge Checks
Keep two independent checks in continuous integration: standard vectors must match byte for byte, and application records must remain readable in their old versions while invalid parameters are rejected. Also include an old record used only for testing. Verify that it still opens after an upgrade and that newly written records actually use the new version.
Failure reports should contain only the test case name, algorithm, and non-sensitive parameters. Do not upload passwords, derived keys, or real user records. Run performance measurements in controlled jobs on physical devices; do not block merges based on a single timing result from a shared execution environment.
Finally, check migration failure paths: after cancellation, a disk write failure, or application termination, the old record must still be readable. This validates more than a password function—it validates an end-to-end process that will not lose data when parameters are upgraded.
Frequently asked questions
Can production use the one- and two-iteration examples?
No. Those counts belong to known-answer tests that detect algorithm or argument wiring errors. Choose a production work factor using security policy and target-device measurements, and cap computation requested by untrusted records.
Does a passing Mac script complete iOS validation?
No. The Mac script checks the algorithm and byte contract quickly. An iOS test target must also exercise the actual application wrapper, and target hardware must be measured for latency and interaction impact.
Rent a cloud Mac
Rent a dedicated physical machine by the day, week, month, or quarter. Schedule builds around your project; service stops when your rental ends unless you renew.
Choose a model