Skip to content

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

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions