Skip to content

RFC 3161 timestamp (sigTst) never embedded — tsa: URL silently ignored #109

@derekeverett

Description

@derekeverett

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.rsdefault_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!

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions