Verifying AI-generated replies

Every reply that a digital employee writes is marked as AI-generated in a machine-readable way, as Article 50(2) of the EU AI Act requires. The mark is a signed record in the open C2PA Content Credentials standard. It states that Freeday declares this exact text AI-generated, and it holds a cryptographic hash of the text. Anyone who has the reply as Freeday sent it can check the record with standard C2PA tools.

What is marked

A reply gets a record when the digital employee has finished it and it contains text. Messages that are still streaming, and messages without text, get no record.

The message carries a provenance field that points to its record:

{
  "message_id": "9648efd9-3c7c-4a17-b94e-28b46e4a15b4",
  "status": "complete",
  "blocks": [{ "type": "markdown", "content": "Your order ships tomorrow." }],
  "provenance": {
    "manifest_id": "0b6f2d4e-8a1c-4f3e-9d27-5c4e1a7b9f20",
    "manifest_url": "https://api.freeday.ai/analytics/v1/public/provenance/0b6f2d4e-8a1c-4f3e-9d27-5c4e1a7b9f20",
    "ai_origin": "ai_generated"
  }
}
Field Meaning
manifest_id Random identifier of the record. It is also the C2PA manifest label, urn:c2pa:<manifest_id>.
manifest_url Public address of the record. No authentication is needed.
ai_origin Who wrote the text. Always ai_generated for now.

Messages without a record have provenance set to null, or have no provenance field.

What the record contains

The record is a C2PA manifest that is stored next to the text, not inside it, so the reply text stays exactly as the user gets it. It contains:

  • A c2pa.created action with the source type http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia, the standard IPTC value for AI-generated content, and the time the reply was sent.
  • A SHA-256 hash of the reply text.
  • A signature by the certificate Freeday Content Credentials, issued by the Freeday root certificate below.

The record holds no conversation, company or end-user data.

The record is available for as long as Freeday keeps the conversation data of your company. After that, manifest_url returns 404.

Verify a reply

  1. Build the reply text from the message. Take the text of each text block and the content of each markdown block, in their order in blocks, and join them with a newline (\n).
  2. Download the record from manifest_url. It is served as application/c2pa. Freeday signs the record a few seconds after it sends the reply, so a 404 for a reply that is less than a minute old means "not ready yet": wait a few seconds and try again.
  3. Check the text against the record with a C2PA verifier, with the Freeday root certificate as a trust anchor.

With the official C2PA Python SDK (pip install c2pa-python):

import io
import json
import time
import urllib.error
import urllib.request

import c2pa

TEXT_FIELD_BY_BLOCK_TYPE = {"text": "text", "markdown": "content"}

def reply_text(message: dict) -> str:
    return "\n".join(
        block[TEXT_FIELD_BY_BLOCK_TYPE[block["type"]]]
        for block in message["blocks"]
        if block["type"] in TEXT_FIELD_BY_BLOCK_TYPE
        and block[TEXT_FIELD_BY_BLOCK_TYPE[block["type"]]]
    )

def download_manifest(manifest_url: str, attempts: int = 5) -> bytes:
    for attempt in range(1, attempts + 1):
        try:
            with urllib.request.urlopen(manifest_url) as response:
                return response.read()
        except urllib.error.HTTPError as error:
            if error.code != 404 or attempt == attempts:
                raise
            time.sleep(3)


def verify_reply(message: dict, root_certificate_pem: str) -> dict:
    manifest = download_manifest(message["provenance"]["manifest_url"])
    settings = c2pa.Settings.from_dict(
        {
            "verify": {"verify_trust": True},
            "trust": {"trust_anchors": root_certificate_pem},
        }
    )
    with c2pa.Reader(
        "text/plain",
        io.BytesIO(reply_text(message).encode("utf-8")),
        manifest_data=manifest,
        context=c2pa.Context(settings=settings),
    ) as reader:
        return json.loads(reader.json())

report = verify_reply(message, open("freeday-content-credentials-root-ca.pem").read())
print(report.get("validation_state"), report.get("validation_status", []))

Other C2PA SDKs (Rust, JavaScript, C/C++) work the same way: read the text as text/plain with the record as an external manifest.

Read the result

validation_state validation_status Meaning
Trusted empty The text is unchanged and Freeday signed the record.
Valid signingCredential.untrusted The text is unchanged, but the verifier does not trust the signer. Add the Freeday root certificate as a trust anchor.
Invalid assertion.dataHash.mismatch The text is not the text Freeday signed. It was changed, or it was built from the blocks in a different way.

Verify the text as Freeday sent it in the message. Text copied from the chat window does not verify, because the widget shows rendered markdown.

Freeday root certificate

Save the certificate below as freeday-content-credentials-root-ca.pem.

Subject CN=Freeday Content Credentials Root CA, O=Freeday
Valid until 10 November 2036
SHA-256 fingerprint 6A:CA:31:87:A3:F7:E4:A5:A6:EE:45:58:0C:13:DE:47:67:17:E1:F8:DC:BE:88:83:2B:23:20:AA:49:DF:23:14
-----BEGIN CERTIFICATE-----
MIIB5TCCAYugAwIBAgIUAKRsgH84xTVmVQtVw4h/UgmiPyswCgYIKoZIzj0EAwIw
QDEQMA4GA1UEChMHRnJlZWRheTEsMCoGA1UEAxMjRnJlZWRheSBDb250ZW50IENy
ZWRlbnRpYWxzIFJvb3QgQ0EwHhcNMjYxMTEzMTAwNjQ5WhcNMzYxMTEwMTAwNjQ4
WjBAMRAwDgYDVQQKEwdGcmVlZGF5MSwwKgYDVQQDEyNGcmVlZGF5IENvbnRlbnQg
Q3JlZGVudGlhbHMgUm9vdCBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABBsE
OkrGvdpbYPDBrTvXg++7HtCc76qSLbZCpqUqcNiEcyN6PW55+U8zn3vw+CMORyYa
a/8X7syjn1BfH+M3l2OjYzBhMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTAD
AQH/MB0GA1UdDgQWBBTFk59o1b4edpuVajH0fmuDmCnreDAfBgNVHSMEGDAWgBTF
k59o1b4edpuVajH0fmuDmCnreDAKBggqhkjOPQQDAgNIADBFAiEA+AgzDMM26+xk
YzIsFfb2Clqg9gTEOYaITFhJxtrZh2gCIGmuRnNyqYd75Eb8YFcvd7SO4RtU/j1J
eOQ6ozKATA30
-----END CERTIFICATE-----

Compare its fingerprint with the table before you add it as a trust anchor:

openssl x509 -in freeday-content-credentials-root-ca.pem -noout -fingerprint -sha256

Freeday's certificate is not on the public C2PA trust list yet, so a verifier without this trust anchor reports the signer as untrusted.