Depuis le 2 août 2026, l’article 50 de l’AI Act impose aux fournisseurs de systèmes d’IA générative de marquer leurs contenus dans un format lisible par machine, de sorte qu’une image, un son, une vidéo ou un texte produits par un modèle puissent être identifiés comme tels. Le texte fixe une obligation de résultat, pas une technique : les solutions doivent être « aussi efficaces, interopérables, solides et fiables que la technologie le permet ». Reste à savoir ce que la technologie permet réellement. Nous avons donc marqué une image générée par IA avec les deux familles de techniques disponibles, un manifeste C2PA signé et deux filigranes invisibles, puis soumis le résultat aux traitements qu’une image subit dès sa publication : recompression, redimensionnement, capture d’écran, recadrage, retouche. Ce tutoriel donne le code complet, en Python, et les résultats mesurés.

Ce que l’article 50 de l’AI Act exige, et de qui
L’article 50 du règlement (UE) 2024/1689 regroupe des obligations de transparence qui ne visent pas les mêmes acteurs. Les confondre est l’erreur la plus fréquente : le fournisseur, celui qui développe le système ou le met sur le marché, n’a pas les mêmes devoirs que le déployeur, l’entreprise qui l’utilise sous sa propre autorité.
- Paragraphe 1, fournisseurs. Un système destiné à interagir directement avec des personnes, un assistant conversationnel par exemple, doit être conçu pour qu’elles sachent qu’elles s’adressent à une IA, sauf si cela est évident pour une personne normalement informée et attentive.
- Paragraphe 2, fournisseurs. Les sorties des systèmes qui génèrent du son, de l’image, de la vidéo ou du texte de synthèse sont marquées dans un format lisible par machine et identifiables comme générées ou manipulées par une IA. L’obligation ne s’applique pas aux fonctions d’assistance à la mise en forme, ni aux systèmes qui ne modifient pas substantiellement les données d’entrée.
- Paragraphe 3, déployeurs. Les personnes exposées à un système de reconnaissance des émotions ou de catégorisation biométrique en sont informées.
- Paragraphe 4, déployeurs. Un hypertrucage (deepfake) est signalé comme tel ; pour une œuvre manifestement artistique, satirique ou fictive, la mention se fait d’une manière qui n’entrave pas sa présentation. Un texte publié pour informer le public sur une question d’intérêt public est signalé comme généré par IA, sauf s’il a fait l’objet d’un examen humain ou d’un contrôle éditorial et qu’une personne en assume la responsabilité éditoriale.
- Paragraphe 5. L’information est donnée de manière claire et reconnaissable, au plus tard lors de la première interaction ou de la première exposition, et respecte les exigences d’accessibilité.
Le calendrier a été précisé par le règlement (UE) 2026/1744 du 8 juillet 2026 : les obligations s’appliquent depuis le 2 août 2026, mais les fournisseurs de systèmes génératifs mis sur le marché avant cette date ont jusqu’au 2 décembre 2026 pour se conformer au paragraphe 2. Le non-respect de l’article 50 expose, selon l’article 99, à une amende pouvant atteindre 15 millions d’euros ou 3 % du chiffre d’affaires annuel mondial, le montant le plus élevé étant retenu ; pour les PME et les jeunes pousses, c’est le plus faible des deux. Enfin, la Commission a publié le 10 juin 2026 un code de bonnes pratiques, d’adhésion volontaire, consacré au marquage (pour les fournisseurs) et à l’étiquetage (pour les déployeurs) ; il propose notamment des icônes européennes facultatives pour signaler un contenu entièrement généré ou partiellement modifié par IA.
Deux familles de techniques : la signature et le filigrane
Le marquage lisible par machine repose aujourd’hui sur deux approches complémentaires.
Le manifeste C2PA (Coalition for Content Provenance and Authenticity, le standard ouvert derrière les « Content Credentials ») est un bloc de métadonnées signé et inséré dans le fichier. Il décrit l’origine du contenu : ici, une action « création » dont le type de source, issu du vocabulaire IPTC, vaut trainedAlgorithmicMedia, c’est-à-dire un média produit par un modèle entraîné. La signature, adossée à un certificat X.509, garantit que le manifeste et les pixels n’ont pas été modifiés depuis. C’est riche, vérifiable et interopérable, mais cela vit dans les métadonnées du fichier.
Le filigrane invisible inscrit quelques octets dans l’image elle-même, par de légères modifications des pixels. Il ne prouve rien à lui seul et ne porte presque aucune information, mais il peut survivre à la perte des métadonnées. Nous en avons testé deux : un filigrane classique par transformée en ondelettes et en cosinus (DWT-DCT, bibliothèque invisible-watermark), et TrustMark, un filigrane appris publié en open source par Adobe sous licence MIT, conçu pour servir de « soft binding » au sens de C2PA, c’est-à-dire de lien entre les pixels et un manifeste conservé ailleurs.
Préparer l’environnement
Le test a été mené sur un Mac (Apple Silicon) avec Python 3.12 ; tout fonctionne aussi sous Linux. La bibliothèque c2pa-python exige Python 3.10 ou plus récent. TrustMark télécharge ses poids (quelques dizaines de mégaoctets) au premier appel et installe PyTorch.
python3.12 -m venv .venv && source .venv/bin/activate
pip install c2pa-python==0.38.2 trustmark==0.9.2 invisible-watermark==0.2.0 pillow cryptography openai
brew install c2patool # outil en ligne de commande de la C2PA, pour inspecter les fichiersUn certificat de signature de test
Un manifeste C2PA est signé par une clé dont le certificat respecte des contraintes précises : il ne doit pas être une autorité de certification, il doit porter une extension d’usage étendu admise par la spécification (ici, la protection des e-mails) et la chaîne fournie au signataire commence par le certificat de signature, suivi de l’autorité. Le script ci-dessous crée une autorité racine de test et un certificat de signature ECDSA P-256. En production, le certificat est délivré par une autorité figurant sur la liste de confiance C2PA et la clé privée reste dans un HSM ou un coffre de clés.
"""Crée une autorité de test et un certificat de signature C2PA (ES256).
Pour un essai seulement : en production, le certificat vient d'une autorité reconnue
par la liste de confiance C2PA, et la clé privée reste dans un HSM ou un coffre de clés.
"""
import datetime
from pathlib import Path
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
DOSSIER = Path(__file__).parent / "certs"
DOSSIER.mkdir(exist_ok=True)
maintenant = datetime.datetime.now(datetime.timezone.utc)
def nom(cn: str) -> x509.Name:
return x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, cn),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Exemple SAS")])
# Autorité racine de test
cle_ac = ec.generate_private_key(ec.SECP256R1())
ac = (
x509.CertificateBuilder()
.subject_name(nom("AC de test C2PA")).issuer_name(nom("AC de test C2PA"))
.public_key(cle_ac.public_key()).serial_number(x509.random_serial_number())
.not_valid_before(maintenant).not_valid_after(maintenant + datetime.timedelta(days=365))
.add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True)
.add_extension(x509.KeyUsage(digital_signature=False, content_commitment=False, key_encipherment=False,
data_encipherment=False, key_agreement=False, key_cert_sign=True,
crl_sign=True, encipher_only=False, decipher_only=False), critical=True)
.add_extension(x509.SubjectKeyIdentifier.from_public_key(cle_ac.public_key()), critical=False)
.sign(cle_ac, hashes.SHA256())
)
# Certificat de signature : la spécification C2PA exige une extension d'usage étendu
# (ici protection des e-mails, l'une des valeurs admises) et interdit l'usage « CA ».
cle = ec.generate_private_key(ec.SECP256R1())
feuille = (
x509.CertificateBuilder()
.subject_name(nom("Générateur d'images - signature C2PA")).issuer_name(ac.subject)
.public_key(cle.public_key()).serial_number(x509.random_serial_number())
.not_valid_before(maintenant).not_valid_after(maintenant + datetime.timedelta(days=90))
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
.add_extension(x509.KeyUsage(digital_signature=True, content_commitment=False, key_encipherment=False,
data_encipherment=False, key_agreement=False, key_cert_sign=False,
crl_sign=False, encipher_only=False, decipher_only=False), critical=True)
.add_extension(x509.ExtendedKeyUsage([ExtendedKeyUsageOID.EMAIL_PROTECTION]), critical=False)
.add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(cle_ac.public_key()), critical=False)
.add_extension(x509.SubjectKeyIdentifier.from_public_key(cle.public_key()), critical=False)
.sign(cle_ac, hashes.SHA256())
)
pem = serialization.Encoding.PEM
# La chaîne attendue par C2PA : le certificat de signature d'abord, puis l'autorité.
(DOSSIER / "chaine.pem").write_bytes(feuille.public_bytes(pem) + ac.public_bytes(pem))
(DOSSIER / "ac.pem").write_bytes(ac.public_bytes(pem))
(DOSSIER / "signature.key").write_bytes(cle.private_bytes(
pem, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()))
print("certs/chaine.pem, certs/ac.pem, certs/signature.key")Étape 1 : inscrire le filigrane dans les pixels
Le filigrane s’inscrit avant la signature : signer, puis modifier les pixels, invaliderait le manifeste. TrustMark, en mode texte, code chaque caractère sur 7 bits ; avec le modèle « Q », l’identifiant tient en 8 caractères. Notre premier essai avec IA26-0001 (9 caractères) a été relu IA26-0000 : aucune erreur n’est levée, la valeur est simplement tronquée. C’est le premier piège à connaître.
"""Filigrane appris (TrustMark, Adobe, licence MIT) : le « soft binding » prévu par C2PA.
Le filigrane porte un identifiant court qui permet de retrouver le manifeste dans un
dépôt quand les métadonnées ont été retirées.
Usage :
python filigrane_trustmark.py inscrire images/genere.jpg images/trustmark.jpg
python filigrane_trustmark.py lire images/fichier.jpg [autres fichiers…]
"""
import sys
from PIL import Image
from trustmark import TrustMark
# Le mode texte code 7 bits par caractère : 8 caractères au plus avec le modèle Q.
IDENTIFIANT = "IA260001"
tm = TrustMark(verbose=False, model_type="Q")
def inscrire(source: str, destination: str) -> None:
image = Image.open(source).convert("RGB")
tm.encode(image, IDENTIFIANT, MODE="text").save(destination, quality=92)
if __name__ == "__main__":
if sys.argv[1] == "inscrire":
inscrire(sys.argv[2], sys.argv[3])
else:
for chemin in sys.argv[2:]:
lu, present, _ = tm.decode(Image.open(chemin).convert("RGB"), MODE="text")
print(f"{chemin} : {'identifiant ' + lu if present else 'filigrane absent ou illisible'}")Le filigrane reste imperceptible : le rapport signal sur bruit (PSNR) entre l’image d’origine et l’image filigranée est de 42,0 dB, contre 41,6 dB pour le filigrane DWT-DCT-SVD.
Étape 2 : signer le manifeste C2PA
Le manifeste déclare l’action c2pa.created et le type de source trainedAlgorithmicMedia, puis, dans une assertion c2pa.soft-binding, l’identifiant du filigrane. Le script dépose aussi une copie du manifeste dans un registre, indexé par cet identifiant : c’est ce registre, tenu par le fournisseur, qui permettra de retrouver la provenance d’une image dont les métadonnées ont été retirées. La signature est horodatée par une autorité d’horodatage publique, faute de quoi elle cesserait d’être vérifiable à l’expiration du certificat.
"""Appose un manifeste C2PA signé à une image produite par un modèle génératif.
Le manifeste déclare l'action « c2pa.created » avec le type de source numérique IPTC
trainedAlgorithmicMedia : c'est le marquage lisible par machine de l'article 50,
paragraphe 2, de l'AI Act, sous la forme que lisent les outils de vérification.
Si un identifiant de filigrane est fourni, le manifeste le déclare dans une assertion
« c2pa.soft-binding » : c'est le lien entre les pixels et le manifeste quand les
métadonnées ont disparu.
Usage : python marquer.py images/trustmark.jpg images/marque.jpg [IA260001]
"""
import json
import sys
from pathlib import Path
import c2pa
CERTS = Path(__file__).parent / "certs"
TRAINED = "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia"
manifeste = {
"claim_generator_info": [{"name": "generateur-images-exemple", "version": "1.0"}],
"title": "Image générée par IA",
"assertions": [
{
"label": "c2pa.actions.v2",
"data": {
"actions": [{
"action": "c2pa.created",
"digitalSourceType": TRAINED,
"softwareAgent": {"name": "modele-image-exemple", "version": "2026-10"},
}]
},
},
],
}
def main(source: str, destination: str, filigrane: str | None = None) -> None:
if filigrane:
manifeste["assertions"].append({
"label": "c2pa.soft-binding",
"data": {"alg": "com.adobe.trustmark.Q", "blocks": [{"scope": {}, "value": filigrane}]},
})
info = c2pa.C2paSignerInfo(
alg=b"es256",
sign_cert=(CERTS / "chaine.pem").read_bytes(),
private_key=(CERTS / "signature.key").read_bytes(),
# Horodatage : sans lui, la signature expire avec le certificat.
ta_url=b"http://timestamp.digicert.com",
)
signer = c2pa.Signer.from_info(info)
with c2pa.Builder(json.dumps(manifeste)) as builder:
builder.sign_file(source, destination, signer)
if filigrane:
# Registre du fournisseur : identifiant du filigrane -> manifeste signé.
registre = Path(__file__).parent / "registre"
registre.mkdir(exist_ok=True)
# Relu avec l'ancre de confiance, pour que le registre garde l'état « Trusted ».
reglages = c2pa.Settings.from_dict({"trust": {"user_anchors": (CERTS / "ac.pem").read_text()}})
with c2pa.Reader(destination, context=c2pa.Context(reglages)) as lecteur:
(registre / f"{filigrane}.json").write_text(lecteur.json())
print(f"{destination} : manifeste C2PA signé")
if __name__ == "__main__":
main(*sys.argv[1:4])L’inspection avec c2patool images/marque.jpg confirme les deux assertions (c2pa.actions.v2 et c2pa.soft-binding), l’état Valid et une seule réserve, attendue : signingCredential.untrusted, puisque notre autorité de test ne figure sur aucune liste de confiance.
Étape 3 : vérifier, avec ou sans métadonnées
Le vérificateur lit d’abord le manifeste intégré, en déclarant l’autorité de test comme ancre de confiance. S’il n’en trouve pas, il cherche le filigrane TrustMark et, si l’identifiant figure au registre, en restitue le manifeste.
"""Lit le manifeste C2PA d'un fichier et dit s'il déclare un contenu généré par IA.
Sans manifeste dans le fichier, le script cherche le filigrane TrustMark et, s'il le
trouve, retrouve le manifeste dans le registre du fournisseur.
Usage : python verifier.py images/marque.jpg [autres fichiers…]
"""
import json
import sys
from pathlib import Path
import c2pa
from PIL import Image
from trustmark import TrustMark
ANCRE = Path(__file__).parent / "certs" / "ac.pem"
REGISTRE = Path(__file__).parent / "registre"
tm = TrustMark(verbose=False, model_type="Q")
GENERE_PAR_IA = {
"http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia",
"http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia",
}
# L'autorité de test est déclarée comme ancre de confiance ; en production, on s'appuie
# sur la liste de confiance publiée par la C2PA.
contexte = c2pa.Context(c2pa.Settings.from_dict({
"trust": {"user_anchors": ANCRE.read_text()},
"verify": {"verify_trust": True},
}))
def resumer(rapport: dict) -> str:
actif = rapport["manifests"][rapport["active_manifest"]]
sources = {
action.get("digitalSourceType")
for assertion in actif["assertions"] if assertion["label"].startswith("c2pa.actions")
for action in assertion["data"]["actions"]
}
erreurs = [s["code"] for s in rapport.get("validation_status") or []]
return (f"état {rapport.get('validation_state')}, "
f"généré par IA : {'oui' if sources & GENERE_PAR_IA else 'non'}, "
f"signataire : {actif['signature_info'].get('common_name')}"
+ (f", anomalies : {', '.join(erreurs)}" if erreurs else ""))
def verifier(chemin: str) -> None:
try:
with c2pa.Reader(chemin, context=contexte) as lecteur:
print(f"{chemin} : manifeste intégré, {resumer(json.loads(lecteur.json()))}")
return
except c2pa.C2paError:
pass
identifiant, present, _ = tm.decode(Image.open(chemin).convert("RGB"), MODE="text")
fiche = REGISTRE / f"{identifiant}.json"
if present and fiche.exists():
# Le manifeste du registre a été validé à l'enregistrement ; on en relit le contenu.
print(f"{chemin} : manifeste absent, filigrane {identifiant} -> registre, "
f"{resumer(json.loads(fiche.read_text()))}")
else:
print(f"{chemin} : ni manifeste ni filigrane lisible")
if __name__ == "__main__":
for chemin in sys.argv[1:]:
verifier(chemin)Étape 4 : soumettre l’image aux traitements courants
Nous avons appliqué à l’image marquée (1 200 × 798 pixels) les traitements qu’elle subit dès qu’elle circule : recompression JPEG, réduction de taille, capture d’écran (pixels recopiés dans un nouveau fichier), recadrage, retouche de contraste et de luminosité, rotation légère, et la modification d’un seul octet du fichier signé.
"""Les traitements que subit une image publiée : recompression, redimensionnement,
capture d'écran, recadrage, et une modification d'un seul octet."""
import sys
from PIL import Image
source = sys.argv[1]
image = Image.open(source)
largeur, hauteur = image.size
image.save("images/recompresse.jpg", quality=75) # réseau social
image.resize((largeur // 2, hauteur // 2)).save("images/redimensionne.jpg", quality=85)
Image.frombytes(image.mode, image.size, image.tobytes()).save("images/capture.png")
image.crop((0, 0, int(largeur * 0.8), int(hauteur * 0.8))).save("images/recadre.jpg", quality=90)
octets = bytearray(open(source, "rb").read())
octets[-200] ^= 0xFF # un octet de l'image
open("images/altere.jpg", "wb").write(octets)"""Altérations plus sévères, pour situer la limite des filigranes."""
import sys
from PIL import Image, ImageEnhance
image = Image.open(sys.argv[1]).convert("RGB")
w, h = image.size
image.save("images/q50.jpg", quality=50)
image.resize((w // 4, h // 4)).save("images/quart.jpg", quality=85)
image.crop((w // 4, h // 4, 3 * w // 4, 3 * h // 4)).save("images/recadre50.jpg", quality=90)
ImageEnhance.Brightness(ImageEnhance.Contrast(image).enhance(1.3)).enhance(1.15).save("images/retouche.jpg", quality=85)
image.rotate(5, expand=False).save("images/rotation.jpg", quality=90)La chaîne complète s’exécute en une commande :
#!/bin/sh
# Chaîne complète : filigrane TrustMark, manifeste signé qui le référence, altérations, vérification.
set -e
P=.venv/bin/python
$P filigrane_trustmark.py inscrire images/genere.jpg images/trustmark.jpg
$P marquer.py images/trustmark.jpg images/marque.jpg IA260001
$P alterer.py images/marque.jpg
$P alterer_fort.py images/marque.jpg
$P verifier.py images/genere.jpg images/marque.jpg images/altere.jpg images/recompresse.jpg \
images/redimensionne.jpg images/capture.png images/recadre.jpg images/q50.jpg images/quart.jpg \
images/retouche.jpg images/recadre50.jpg images/rotation.jpgRésultats : le manifeste disparaît, le filigrane appris résiste
images/marque.jpg : manifeste C2PA signé
images/genere.jpg : ni manifeste ni filigrane lisible
images/marque.jpg : manifeste intégré, état Trusted, généré par IA : oui, signataire : Générateur d'images - signature C2PA
images/altere.jpg : manifeste intégré, état Invalid, généré par IA : oui, signataire : Générateur d'images - signature C2PA, anomalies : assertion.dataHash.mismatch
images/recompresse.jpg : manifeste absent, filigrane IA260001 -> registre, état Trusted, généré par IA : oui, signataire : Générateur d'images - signature C2PA
images/redimensionne.jpg : manifeste absent, filigrane IA260001 -> registre, état Trusted, généré par IA : oui, signataire : Générateur d'images - signature C2PA
images/capture.png : manifeste absent, filigrane IA260001 -> registre, état Trusted, généré par IA : oui, signataire : Générateur d'images - signature C2PA
images/recadre.jpg : manifeste absent, filigrane IA260001 -> registre, état Trusted, généré par IA : oui, signataire : Générateur d'images - signature C2PA
images/q50.jpg : manifeste absent, filigrane IA260001 -> registre, état Trusted, généré par IA : oui, signataire : Générateur d'images - signature C2PA
images/quart.jpg : manifeste absent, filigrane IA260001 -> registre, état Trusted, généré par IA : oui, signataire : Générateur d'images - signature C2PA
images/retouche.jpg : manifeste absent, filigrane IA260001 -> registre, état Trusted, généré par IA : oui, signataire : Générateur d'images - signature C2PA
images/recadre50.jpg : ni manifeste ni filigrane lisible
images/rotation.jpg : ni manifeste ni filigrane lisible
Trois enseignements se dégagent.
- Le manifeste C2PA est fiable mais fragile. Il détecte la moindre falsification : un seul octet modifié suffit pour que le vérificateur déclare le manifeste
Invalid(assertion.dataHash.mismatch). En revanche, il disparaît dès la première recompression, la première réduction ou la première capture d’écran : toute chaîne qui réencode l’image, comme la plupart des réseaux sociaux et des systèmes de gestion de contenu, le supprime. - Un filigrane classique ne suffit pas. Le filigrane DWT-DCT simple n’a même pas survécu à l’enregistrement en JPEG de qualité 92 de l’image filigranée. La variante DWT-DCT-SVD tient à une recompression unique de qualité 75 et à la capture d’écran, mais pas à la double compression (92 puis 75), ni au redimensionnement, ni au recadrage. La bibliothèque refuse par ailleurs toute image de moins de 256 pixels de côté.
- Le filigrane appris tient, jusqu’à une limite. TrustMark a été relu après la recompression en qualité 50, la réduction au quart de la taille, la capture d’écran, le recadrage à 80 % et la retouche. Il a été perdu après un recadrage central à 50 % et une rotation de 5 degrés. Sur l’image d’origine, jamais filigranée, il n’a détecté aucun identifiant.
La combinaison des deux couches donne le comportement recherché : dans sept cas sur neuf, le vérificateur a retrouvé, à partir des seuls pixels, le manifeste complet et signé dans le registre, avec l’état Trusted. C’est la raison pour laquelle aucune technique isolée ne répond à l’exigence de robustesse de l’article 50 : la signature apporte la preuve, le filigrane la persistance, et le registre fait le lien entre les deux.
Et le texte ? Information, étiquetage et registre d’empreintes
Le texte est le cas le plus difficile. Il n’a pas de métadonnées qui le suivent au copier-coller, et un filigrane statistique inscrit dans le choix des mots ne se met en place qu’au moment de la génération, au niveau du modèle. Côté application, trois mesures restent à la portée de tout fournisseur ou déployeur : informer l’utilisateur dès le premier échange (paragraphes 1 et 5), joindre la provenance aux réponses que l’API renvoie, et conserver l’empreinte de chaque texte produit pour pouvoir confirmer plus tard qu’il sort bien du système.
"""Assistant conversationnel conforme à l'article 50 : information au premier échange,
réponses marquées, empreinte de chaque texte produit dans le registre du fournisseur.
Tout serveur compatible OpenAI convient (vLLM, Ollama…), ainsi qu'Azure OpenAI.
Usage :
export LLM_BASE_URL=http://localhost:11434/v1 LLM_MODEL=qwen3.5:9b
python chatbot.py
"""
import datetime
import hashlib
import json
import os
import unicodedata
from pathlib import Path
from openai import AzureOpenAI, OpenAI
MODEL = os.environ.get("LLM_MODEL", "qwen3.5:9b")
if os.environ.get("LLM_PROVIDER") == "azure":
client = AzureOpenAI(azure_endpoint=os.environ["AOAI_ENDPOINT"], api_key=os.environ["AOAI_KEY"],
api_version="2024-10-21")
else:
client = OpenAI(base_url=os.environ.get("LLM_BASE_URL", "http://localhost:11434/v1"),
api_key=os.environ.get("LLM_API_KEY", "local"))
REGISTRE = Path(__file__).parent / "registre" / "textes.jsonl"
# Article 50, paragraphes 1 et 5 : informer clairement, au plus tard au premier échange.
INFORMATION = ("Vous échangez avec un assistant fondé sur l'intelligence artificielle. "
"Ses réponses sont générées automatiquement et peuvent comporter des erreurs.")
def empreinte(texte: str) -> str:
"""Empreinte du texte normalisé (Unicode, espaces) : résiste à la mise en forme, pas à la réécriture."""
normalise = " ".join(unicodedata.normalize("NFKC", texte).split())
return hashlib.sha256(normalise.encode()).hexdigest()
def repondre(historique: list[dict]) -> dict:
reponse = client.chat.completions.create(model=MODEL, messages=historique)
texte = reponse.choices[0].message.content
# Marquage lisible par machine : la réponse de l'API porte la provenance ; le registre
# garde l'empreinte, pour confirmer plus tard qu'un texte sort bien de ce système.
REGISTRE.parent.mkdir(exist_ok=True)
with REGISTRE.open("a") as f:
f.write(json.dumps({"sha256": empreinte(texte), "modele": MODEL,
"date": datetime.datetime.now(datetime.timezone.utc).isoformat()}) + "\n")
return {
"texte": texte,
"provenance": {
"genere_par_ia": True,
"digital_source_type": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia",
"modele": MODEL,
"sha256": empreinte(texte),
},
}
def connu(texte: str) -> bool:
if not REGISTRE.exists():
return False
cible = empreinte(texte)
return any(json.loads(ligne)["sha256"] == cible for ligne in REGISTRE.open())
def main() -> None:
print(INFORMATION + "\n")
historique = [{"role": "system", "content": "Tu es l'assistant du service client. Réponds en français, brièvement."}]
while (question := input("> ").strip()):
historique.append({"role": "user", "content": question})
r = repondre(historique)
historique.append({"role": "assistant", "content": r["texte"]})
print(r["texte"] + "\n")
if __name__ == "__main__":
main()Nous avons testé ce registre avec une réponse réelle d’un modèle servi par une API compatible OpenAI. L’empreinte normalisée reconnaît le texte copié à l’identique, même avec des espaces et des retours à la ligne différents. Elle ne le reconnaît plus dès qu’un mot est changé, qu’une virgule est retirée, qu’une phrase est ajoutée, ou que le texte est copié depuis la page affichée, où le gras Markdown (**) a disparu. L’empreinte prouve donc qu’un texte vient du système ; elle ne permet pas de détecter un texte retravaillé. Le test a d’ailleurs illustré l’utilité de la mention : interrogé sur les horaires du samedi d’un service client fictif, le modèle a répondu « généralement de 9 h à 12 h et de 14 h à 18 h », des horaires qu’aucune donnée ne lui avait fournis.
Pour le déployeur qui publie des textes d’intérêt public, la règle du paragraphe 4 se traduit directement en code : la mention est due, sauf si le texte a été relu et qu’une personne en assume la responsabilité éditoriale.
"""Article 50, paragraphe 4 : étiquetage par le déployeur d'un texte publié pour informer
le public sur une question d'intérêt public.
L'exception joue seulement si le texte a fait l'objet d'un examen humain ou d'un
contrôle éditorial ET qu'une personne assume la responsabilité éditoriale de la publication.
"""
from dataclasses import dataclass
from html import escape
@dataclass
class Publication:
texte: str
genere_par_ia: bool
interet_public: bool
relu_par: str | None = None # examen humain ou contrôle éditorial
responsable_editorial: str | None = None
def mention(p: Publication) -> str | None:
if not (p.genere_par_ia and p.interet_public):
return None
if p.relu_par and p.responsable_editorial:
return None
return "Ce texte a été généré par un système d'intelligence artificielle."
def en_html(p: Publication) -> str:
m = mention(p)
bandeau = f'<p class="mention-ia" role="note">{escape(m)}</p>\n' if m else ""
return f"<article>\n{bandeau}<div>{escape(p.texte)}</div>\n</article>"
if __name__ == "__main__":
texte = "La commune ouvre trois centres de vaccination à partir de lundi."
print(en_html(Publication(texte, genere_par_ia=True, interet_public=True)))
print(en_html(Publication(texte, genere_par_ia=True, interet_public=True,
relu_par="M. Martin", responsable_editorial="Rédaction en chef")))Ce qu’il faut retenir pour un projet
- Le fournisseur marque à la source : manifeste C2PA signé et horodaté, filigrane robuste inscrit avant la signature, registre qui relie l’identifiant du filigrane au manifeste.
- Le déployeur étiquette ce qu’il diffuse : hypertrucages, textes d’intérêt public non relus, et information des utilisateurs d’un assistant dès le premier échange.
- La chaîne de publication compte autant que le marquage : un CMS ou un CDN qui réencode les images supprime le manifeste. Il faut le vérifier sur sa propre chaîne, avec le script de vérification ci-dessus.
- Les filigranes ont des limites mesurables : recadrage serré et rotation suffisent à les effacer. Il faut documenter ces limites plutôt que promettre une détection infaillible.
- En production, la clé de signature reste dans un HSM ou un coffre de clés, et le certificat est émis par une autorité reconnue par la liste de confiance C2PA.
Questions fréquentes
Quand l’article 50 de l’AI Act s’applique-t-il ?
Depuis le 2 août 2026. Les fournisseurs de systèmes d’IA générative mis sur le marché avant cette date disposent d’un délai jusqu’au 2 décembre 2026 pour l’obligation de marquage du paragraphe 2, en application du règlement (UE) 2026/1744.
Quelle amende en cas de non-respect de l’article 50 ?
Jusqu’à 15 millions d’euros ou 3 % du chiffre d’affaires annuel mondial, le montant le plus élevé étant retenu (article 99, paragraphe 4). Pour les PME et les jeunes pousses, le plafond retenu est le plus faible des deux.
C2PA est-il obligatoire ?
Non. L’AI Act n’impose aucune technique ; il exige un marquage lisible par machine, efficace, interopérable et robuste. C2PA est le standard ouvert le plus répandu pour la provenance des contenus, ce qui en fait un choix naturel pour l’interopérabilité, mais notre test montre qu’il doit être complété par un filigrane.
Comment détecter une image générée par IA ?
Si l’image porte un manifeste C2PA, c2patool ou le script de vérification ci-dessus en donne l’origine et vérifie la signature. Si les métadonnées ont été retirées, seul un filigrane permet de retrouver la provenance, et seulement si l’on dispose du décodeur correspondant. En l’absence de marquage, les détecteurs statistiques restent des estimations, non des preuves.
Une entreprise qui utilise un assistant IA est-elle concernée ?
Oui, comme déployeur : elle doit signaler les hypertrucages qu’elle diffuse et les textes d’intérêt public générés par IA qu’elle publie sans relecture éditoriale. L’information des utilisateurs d’un assistant conversationnel incombe au fournisseur, qui doit concevoir le système en conséquence ; en pratique, l’entreprise qui le développe pour ses clients endosse souvent ce rôle.
Un filigrane invisible dégrade-t-il l’image ?
Pas de façon perceptible dans notre test : le PSNR entre l’image d’origine et l’image filigranée est d’environ 42 dB pour les deux filigranes testés, un niveau généralement considéré comme invisible à l’œil.



