Summary
When constructing a Signer with a tsa: URL (e.g. URL(string: "http://timestamp.digicert.com")), the resulting signed asset's COSE_Sign1 unprotected header contains only pad — no sigTst RFC 3161 timestamp token is embedded. No error is reported back to the caller; the sign operation succeeds and produces an otherwise-valid manifest, just without a trusted timestamp.
Verified with c2pa-ios v0.0.9, on iOS 18.x device (real hardware, not simulator), signing JPEGs via both the SecureEnclaveSigner path and the plain Signer(certsPEM:privateKeyPEM:algorithm:tsa:) path. Same result for multiple public TSA URLs (DigiCert, Apple, Sectigo).
Root cause
The underlying c2pa-rs library does not itself make HTTP calls — it requires the host application to register an HttpResolver trait object, which the library then invokes for the TSA round-trip. See sdk/src/crypto/time_stamp/http_request.rs — default_rfc3161_request(...) takes http_resolver: &(impl SyncHttpResolver + ?Sized).
The corresponding C FFI function exists in the C2PAC.xcframework's c2pa.h:
int c2pa_context_builder_set_http_resolver(struct C2paContextBuilder *builder, ...);
…but c2pa-ios's public Swift Signer initializers (e.g. Signer.init(certsPEM:privateKeyPEM:algorithm:tsa:) and SecureEnclaveSigner.init(algorithm:certificateChainPEM:tsa:secureEnclaveConfig:)) call the legacy c2pa_signer_create(...) path, which does not go through a C2paContextBuilder and therefore has no place to register an HttpResolver.
When c2pa-rs then attempts the TSA round-trip with no resolver registered, the call fails internally and the error is swallowed — the binary contains the string "HTTP callback returned error", which is the symptom of an unregistered resolver. The signature is produced without a sigTst, the Swift caller sees a successful sign, and no error is reported.
Supporting evidence that the binary itself is otherwise capable:
- The XCFramework binary links
reqwest + hyper symbols (e.g. hyper_util::client::legacy::connect::http::HttpInfo, reqwest::blocking::client) and contains the strings application/timestamp-reply, application/timestamp-query, and ta_url.
- The C FFI header declares
c2pa_context_builder_set_http_resolver — the wiring exists in C, just isn't surfaced in Swift.
There appears to be no test in TestShared/ that exercises the tsa: parameter with a non-nil URL. Every existing test passes tsa: nil (greppable across KeychainSignerTests.swift, HardwareSigningTests.swift, CertificateManagerTests.swift, StreamTests.swift, SignerExtendedTests.swift, TestUtilities.swift, etc.). This appears to be why the gap has gone unnoticed.
Reproduction
To verify, add the following test to TestShared/Sources/SigningTests.swift (or any existing test file). It uses the existing es256_certs.pem / es256_private.key fixtures and a public RFC 3161 TSA URL.
public func testSignerEmbedsTrustedTimestamp() -> TestResult {
do {
let certsPEM = TestUtilities.loadTestCerts()
let keyPEM = TestUtilities.loadTestPrivateKey()
guard let sample = Bundle.module.url(forResource: "sample", withExtension: "jpg")
else { return .failure("TSA Timestamp", "sample.jpg not in test bundle") }
let signer = try Signer(
certsPEM: certsPEM,
privateKeyPEM: keyPEM,
algorithm: .es256,
tsa: URL(string: "http://timestamp.digicert.com")!
)
let manifestJSON = """
{
"claim_generator": "tsa-test/1.0",
"claim_version": 1,
"assertions": [
{"label": "c2pa.actions", "data": {"actions": [{"action": "c2pa.created"}]}}
]
}
"""
let builder = try Builder(manifestJSON: manifestJSON)
let dst = FileManager.default.temporaryDirectory
.appendingPathComponent("tsa-test-\(UUID().uuidString).jpg")
defer { try? FileManager.default.removeItem(at: dst) }
try builder.sign(format: "image/jpeg",
source: try Stream(readFrom: sample),
destination: try Stream(writeTo: dst),
signer: signer)
// Look for the CBOR text key "sigTst" anywhere in the signed file.
// CBOR encodes "sigTst" (6-char text string) as bytes:
// 0x66 0x73 0x69 0x67 0x54 0x73 0x74 → "f sigTst"
let signed = try Data(contentsOf: dst)
let sigTstMarker: [UInt8] = [0x66, 0x73, 0x69, 0x67, 0x54, 0x73, 0x74]
let found = signed.range(of: Data(sigTstMarker)) != nil
return found
? .success("TSA Timestamp", "[PASS] sigTst present in signed manifest")
: .failure("TSA Timestamp", "[FAIL] sigTst NOT embedded — tsa URL was silently ignored")
} catch {
return .failure("TSA Timestamp", "Failed: \(error)")
}
}
Expected
The test passes — sigTst byte sequence is present in the signed file's COSE_Sign1 unprotected header.
Observed
The test fails — sigTst byte sequence is absent. The COSE unprotected header contains only pad.
Independently confirmable by parsing the resulting JPEG's c2pa.signature JUMBF CBOR box and inspecting cose_sign1_arr[1] (the unprotected header map). The only key present is "pad".
Environment
- c2pa-ios
v0.0.9 (binary XCFramework, bundled c2pa-rs 0.79.5)
- iOS 18.x on real hardware (also reproducible on Simulator)
- Xcode 16.x
Happy to test a fix or provide additional diagnostics.
Thanks!
Summary
When constructing a
Signerwith atsa:URL (e.g.URL(string: "http://timestamp.digicert.com")), the resulting signed asset'sCOSE_Sign1unprotected header contains onlypad— nosigTstRFC 3161 timestamp token is embedded. No error is reported back to the caller; the sign operation succeeds and produces an otherwise-valid manifest, just without a trusted timestamp.Verified with c2pa-ios
v0.0.9, on iOS 18.x device (real hardware, not simulator), signing JPEGs via both theSecureEnclaveSignerpath and the plainSigner(certsPEM:privateKeyPEM:algorithm:tsa:)path. Same result for multiple public TSA URLs (DigiCert, Apple, Sectigo).Root cause
The underlying
c2pa-rslibrary does not itself make HTTP calls — it requires the host application to register anHttpResolvertrait object, which the library then invokes for the TSA round-trip. Seesdk/src/crypto/time_stamp/http_request.rs—default_rfc3161_request(...)takeshttp_resolver: &(impl SyncHttpResolver + ?Sized).The corresponding C FFI function exists in the
C2PAC.xcframework'sc2pa.h:…but
c2pa-ios's public SwiftSignerinitializers (e.g.Signer.init(certsPEM:privateKeyPEM:algorithm:tsa:)andSecureEnclaveSigner.init(algorithm:certificateChainPEM:tsa:secureEnclaveConfig:)) call the legacyc2pa_signer_create(...)path, which does not go through aC2paContextBuilderand therefore has no place to register anHttpResolver.When
c2pa-rsthen attempts the TSA round-trip with no resolver registered, the call fails internally and the error is swallowed — the binary contains the string"HTTP callback returned error", which is the symptom of an unregistered resolver. The signature is produced without asigTst, the Swift caller sees a successful sign, and no error is reported.Supporting evidence that the binary itself is otherwise capable:
reqwest+hypersymbols (e.g.hyper_util::client::legacy::connect::http::HttpInfo,reqwest::blocking::client) and contains the stringsapplication/timestamp-reply,application/timestamp-query, andta_url.c2pa_context_builder_set_http_resolver— the wiring exists in C, just isn't surfaced in Swift.There appears to be no test in
TestShared/that exercises thetsa:parameter with a non-nilURL. Every existing test passestsa: nil(greppable acrossKeychainSignerTests.swift,HardwareSigningTests.swift,CertificateManagerTests.swift,StreamTests.swift,SignerExtendedTests.swift,TestUtilities.swift, etc.). This appears to be why the gap has gone unnoticed.Reproduction
To verify, add the following test to
TestShared/Sources/SigningTests.swift(or any existing test file). It uses the existinges256_certs.pem/es256_private.keyfixtures and a public RFC 3161 TSA URL.Expected
The test passes —
sigTstbyte sequence is present in the signed file's COSE_Sign1 unprotected header.Observed
The test fails —
sigTstbyte sequence is absent. The COSE unprotected header contains onlypad.Independently confirmable by parsing the resulting JPEG's
c2pa.signatureJUMBF CBOR box and inspectingcose_sign1_arr[1](the unprotected header map). The only key present is"pad".Environment
v0.0.9(binary XCFramework, bundled c2pa-rs0.79.5)Happy to test a fix or provide additional diagnostics.
Thanks!