When teams across time zones hand off signing configurations, a common problem is “the server signs successfully, but iOS verification fails.” Both sides display the same JSON, and regenerating the keys does not help. Start by establishing a verification baseline on a cloud Mac that does not depend on a business API, then compare the raw bytes. This separates problems in cryptographic calls from transport decoding and message reconstruction. The examples below use Swift and CryptoKit with public test data only; no production private keys are needed.
Define the algorithm and input contract first
Specify standard Ed25519, with a 32-byte raw public key and a 64-byte raw signature. The corresponding CryptoKit type is Curve25519.Signing.PublicKey; do not substitute a key-agreement type just because its name looks similar.
The signing party must state whether it signs the raw response-body bytes or the output of a canonicalization procedure. If it signs the raw body, the client must verify before parsing: parsing the JSON and re-encoding it for verification is not valid. If both sides agree to canonicalization, pin the rules version and test samples.
An Ed25519 signature establishes that a message matches a trusted public key. It does not provide confidentiality or automatically prevent replay attacks. If the public key comes from the same response, which can itself be replaced, successful verification is still not enough to establish trust.
Also distinguish standard Ed25519 from Ed25519ph. Hashing the message first and passing that digest to the standard interface does not switch to the prehash variant. A protocol cannot leave out these conditions simply because “it is all Ed25519.”
Run a minimal check with a public reference vector
On a cloud Mac with the development tools already installed, run xcrun swift --version and record the toolchain version along with the output of sw_vers. Save the following code as verify.swift, then run xcrun swift verify.swift. It uses the empty-message test vector from RFC 8032; the public hexadecimal data is not a business key.
import Foundation
import CryptoKit
enum FixtureError: Error {
case invalidHex
case checkFailed
}
func hex(_ text: String) throws -> Data {
let chars = Array(text.utf8)
guard chars.count % 2 == 0 else {
throw FixtureError.invalidHex
}
var bytes = [UInt8]()
for offset in stride(from: 0, to: chars.count, by: 2) {
let pair = String(
decoding: chars[offset..<(offset + 2)],
as: UTF8.self
)
guard let value = UInt8(pair, radix: 16) else {
throw FixtureError.invalidHex
}
bytes.append(value)
}
return Data(bytes)
}
func require(_ value: Bool) throws {
guard value else {
throw FixtureError.checkFailed
}
}
let publicBytes = try hex(
"d75a980182b10ab7d54bfed3c964073a0" +
"ee172f3daa62325af021a68f707511a"
)
let signature = try hex(
"e5564300c360ac729086e2cc806e828a8" +
"4877f1eb8e5d974d873e0652249015555" +
"5fb8821590a33bacc61e39701cf9b46bd" +
"25bf5f0595bbe24655141438e7a100b"
)
try require(publicBytes.count == 32)
try require(signature.count == 64)
let key = try Curve25519.Signing.PublicKey(
rawRepresentation: publicBytes
)
try require(key.isValidSignature(signature, for: Data()))
try require(!key.isValidSignature(
signature, for: Data([0x00])
))
var altered = signature
altered[altered.startIndex] ^= 1
try require(!key.isValidSignature(altered, for: Data()))
try require(!key.isValidSignature(
Data(signature.dropLast()), for: Data()
))
var rejectedKey = false
do {
_ = try Curve25519.Signing.PublicKey(
rawRepresentation: Data(publicBytes.dropLast())
)
} catch {
rejectedKey = true
}
try require(rejectedKey)
The script produces no output on success. On failure, it throws an error and exits with a nonzero status. These checks throw rather than use assert, so an optimized build cannot remove them. Confirm that this fixed data passes before introducing business samples; debugging networking and cryptographic interfaces at the same time adds more variables.
Narrow text differences down to byte differences
Keep one file containing the message before signing and another containing what the client actually received. For public fixtures, compare them with cmp, then use xxd -g 1 to locate the first difference. Production response bodies may contain sensitive information and should not be copied directly into shared logs.
Start with content that editors can easily change: an extra trailing newline, LF versus CRLF line endings, a UTF-8 BOM at the beginning of the file, or Unicode normalization. Accented characters that look identical can still have different byte sequences.
Do not casually use echo to create files; it may append a newline. Read the message as raw file data rather than converting it to a string and back to data. If the protocol does sign strings, explicitly require UTF-8 and create separate fixtures for trailing newlines, empty messages, and combining characters.
Validate the transport envelope separately
If the API sends Base64, decode it before checking the length; 128 hexadecimal characters are not a 128-byte signature. A raw public key is also not the same as a key file containing headers. Do not pass the entire wrapped representation to rawRepresentation.
Never fall back to empty data after a decoding failure and continue verification. Use separate internal error types for “invalid envelope,” “incorrect length,” and “signature mismatch,” while avoiding unnecessary parsing details in externally exposed errors.
Build negative cases that catch integration mistakes
The minimum test set must include a correct message with its correct signature, a message with one zero byte appended, a signature with one bit flipped, a truncated signature, a truncated public key, and verification of the original signature using a different valid public key. That last case must use a genuinely valid, different public key—not merely a damaged key that fails during initialization.
At the business layer, add a sample with the correct body but a signature belonging to the previous message, to check for incorrect reuse of cached results. If the signed content includes a version, validity period, or purpose identifier, also verify that these fields are actually included in the signed bytes rather than appearing only in an unsigned outer object.
These tests exercise rejection paths; do not replace fixed fixtures with random corruption. Fixed samples let team members on different nodes and shifts reproduce the same failure.
Complete the cross-region handoff with an evidence checklist
For the handoff, record the commit ID, OS and toolchain versions, algorithm variant, public-key identifier, message byte count, signature byte count, and test results. Public fixtures can be saved alongside these records. Retain real business messages under controls appropriate to their sensitivity; a digest cannot replace the original message for signature verification either.
Once the command-line baseline passes, add the same fixtures to the iOS test target and call the verification wrapper the application actually uses. This covers Base64 decoding, public-key selection, and message-reading paths rather than merely proving that CryptoKit itself works.
The final acceptance criterion is not “it verified once.” Correct inputs must pass consistently, every negative case must clearly fail, both sides must agree unambiguously on the signed bytes, and the next shift must be able to rerun every check without a private key.
Frequently asked questions
Does client-side verification require the private key?
No. Verification needs a trusted public key, the original message bytes and the signature. Keep production private keys out of the test repository and verification environment.
Why can equivalent JSON objects fail signature verification?
Ed25519 verifies bytes, not JSON semantics. Key order, whitespace and Unicode representation can change the message. Verify the original received bytes or agree on a shared canonical format.
Choose a cloud Mac for your next task
Choose a location near where most of your team is based, and use a dedicated physical Mac mini. Browser-based remote desktop requires a modern browser and a stable internet connection. Check the configuration, billing period, and availability before ordering.