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.createdaction with the source typehttp://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
-
Build the reply text from the message. Take the
textof eachtextblock and thecontentof eachmarkdownblock, in their order inblocks, and join them with a newline (\n). -
Download the record from
manifest_url. It is served asapplication/c2pa. Freeday signs the record a few seconds after it sends the reply, so a404for a reply that is less than a minute old means "not ready yet": wait a few seconds and try again. - 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.