C2PA: 이미지가 어디서 왔는지 서명으로 증명하기

C2PA: 이미지가 어디서 왔는지 서명으로 증명하기

LinkedIn을 넘기다가 이미지 모서리에 붙은 작은 Cr 배지를 보신 적 있으신가요? 눌러 보면 이 이미지를 어떤 도구로 언제 만들었는지, 생성형 AI가 관여했는지가 주르륵 나옵니다. 😮

궁금해서 뜯어봤더니 LinkedIn이 이미지를 분석해 알아낸 게 아니었습니다. 이미지 파일 안에 그 기록이 서명된 채로 들어 있고, LinkedIn은 그걸 읽어서 보여줄 뿐이었는데요. 그 기록의 표준이 C2PA(Coalition for Content Provenance and Authenticity)입니다.

생각해 보면 필요한 물건입니다. 사진 한 장을 앞에 두고 카메라로 찍은 것인지, 모델이 만들어 낸 것인지, 찍은 사진을 고친 것인지 가려내야 하는 상황이 부쩍 늘었으니까요. 픽셀만 들여다봐서는 답이 안 나오죠. C2PA는 파일 안에 출처 기록을 서명해 넣어 두는 방식으로 이 문제에 접근합니다.

이 글에서는 공식 CLI인 c2patool로 그 기록을 직접 만들어 붙여 보고, 변조를 어떻게 잡아내는지, 그리고 어디서 허무하게 무너지는지 실제로 돌려보며 확인해 보겠습니다. 그러고 나서 도입부의 그 배지로 다시 돌아오려 합니다.

자격 증명은 영양 성분표에 가깝습니다

C2PA는 콘텐츠의 출처와 편집 이력을 기록하는 개방형 기술 표준입니다. Adobe와 Google, Meta, Microsoft, OpenAI, Amazon, BBC, Sony, TikTok 같은 곳이 운영위원회에 이름을 올려 두었고, OpenAI와 Microsoft는 자사 서비스가 만들어 내는 이미지에 이 기록을 붙이고 있습니다.

이 표준을 따라 파일에 붙는 기록을 자격 증명(Content Credentials)이라고 부릅니다. C2PA 쪽 설명은 이것을 “디지털 콘텐츠를 위한 영양 성분표”에 비유하는데요. 꽤 정확한 비유입니다. 성분표는 무엇이 들어갔는지 알려줄 뿐 그 음식이 몸에 좋은지까지 판정해 주지는 않으니까요. 자격 증명도 이 파일이 어떤 경로로 만들어졌는지 적어 줄 뿐, 담긴 내용이 참인지 거짓인지는 말해 주지 않습니다.

매니페스트 안에는 무엇이 들어 있을까요

명세에 나오는 구조 용어가 조금 있는데, 다섯 가지만 알면 나머지가 따라옵니다.

  • 어서션(assertion): 자산을 두고 하는 진술 하나하나입니다. “AI가 생성했다”, “밝기를 조정했다”, “썸네일은 이것이다” 같은 항목입니다.
  • 어서션 스토어(assertion store): 한 매니페스트가 참조하는 어서션이 모여 있는 공간입니다.
  • 클레임(claim): 어서션을 참조하면서 콘텐츠와 묶어 주는 정보까지 담은 자료 구조입니다. 명세는 이를 “변조가 드러나는 자료 구조”라고 표현합니다.
  • 클레임 서명(claim signature): 서명자의 개인 키로 클레임에 건 디지털 서명입니다.
  • 매니페스트(manifest): 위의 셋을 하나로 묶은 단위입니다. 여러 매니페스트가 모이면 매니페스트 스토어가 됩니다.

정리하면 어서션이 사실을 적고, 클레임이 그 사실을 한데 묶어 콘텐츠에 붙들어 매고, 서명이 그 묶음을 봉인하는 구조입니다.

flowchart TB
    accTitle: C2PA 매니페스트의 구성과 파일 삽입 과정
    accDescr: 어서션 여러 개가 어서션 스토어에 모이고, 클레임이 이들을 참조하면서 콘텐츠 해시를 담는다. 클레임에 개인 키로 서명한 클레임 서명까지 묶여 매니페스트가 되고, 매니페스트는 JUMBF 박스로 인코딩되어 이미지 파일 안에 들어간다.

    subgraph mf["매니페스트"]
        direction TB
        a1["어서션<br/>AI가 생성했다"] --> store["어서션 스토어"]
        a2["어서션<br/>썸네일"] --> store
        store --> claim["클레임<br/>어서션 참조와 콘텐츠 해시"]
        claim --> sig["클레임 서명<br/>개인 키로 봉인"]
    end

    mf --> jumbf["JUMBF 박스로 인코딩"]
    jumbf --> file[/"이미지 파일"/]

여기서 콘텐츠 해시가 하는 일이 중요합니다. 명세는 이것을 하드 바인딩(hard binding)이라고 부르고 “자산 전체 또는 일부를 유일하게 식별하는 암호학적 해시”로 정의하는데요. 뒤에서 이 해시가 어떻게 변조를 잡아내는지 직접 보겠습니다.

완성된 매니페스트는 JUMBF라는 박스 형식으로 인코딩돼 파일 안에 들어갑니다. JPEG와 PNG, GIF, SVG, TIFF 같은 이미지는 물론이고 MP4, PDF, 그리고 폰트 파일까지 지원 목록에 올라 있습니다.

직접 서명해 봅시다

말로만 보면 감이 잘 안 오니 직접 만들어 보겠습니다. c2patool은 릴리스 페이지에서 받을 수 있습니다.

curl -L -o c2patool.zip https://github.com/contentauth/c2pa-rs/releases/download/c2patool-v0.27.20/c2patool-v0.27.20-universal-apple-darwin.zip
unzip c2patool.zip
cd c2patool && chmod +x c2patool
./c2patool --version
결과
c2patool 0.27.20

압축을 풀면 sample 폴더에 테스트용 인증서와 이미지가 함께 들어 있어 곧바로 실습에 들어갈 수 있죠. 먼저 어떤 내용을 새길지 매니페스트 정의를 JSON으로 씁니다.

ai-manifest.json
{
  "claim_generator_info": [{ "name": "My AI App", "version": "1.0.0" }],
  "title": "생성된 이미지",
  "assertions": [
    {
      "label": "c2pa.actions",
      "data": {
        "actions": [
          {
            "action": "c2pa.created",
            "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia"
          }
        ]
      }
    }
  ]
}

digitalSourceType에 넣은 값이 이 글의 핵심입니다. trainedAlgorithmicMedia는 IPTC가 정한 코드로 “학습된 알고리즘이 만들어 낸 매체”라는 뜻인데요. AI가 만든 이미지라는 사실을 이 한 줄이 기계가 읽을 수 있는 형태로 선언합니다.

서명에 쓸 키는 설정 파일로 넘깁니다.

settings.toml
[signer.local]
alg = "es256"
sign_cert = """
-----BEGIN CERTIFICATE-----
...sample/es256_certs.pem 내용...
-----END CERTIFICATE-----
"""
private_key = """
-----BEGIN PRIVATE KEY-----
...sample/es256_private.key 내용...
-----END PRIVATE KEY-----
"""

파일 경로가 아니라 PEM 내용을 그대로 넣어야 한다는 점을 주의하세요. 경로를 넣으면 디코딩 단계에서 실패합니다. 이제 서명해 보겠습니다.

./c2patool sample/image.jpg --manifest ai-manifest.json --output signed.jpg --settings settings.toml
결과
{
  "active_manifest": "urn:c2pa:debad325-ca4c-4d9d-8261-8fb85fa97cbe",
  "manifests": {
    "urn:c2pa:debad325-ca4c-4d9d-8261-8fb85fa97cbe": {
      "claim_generator_info": [
        {
          "name": "My AI App",
          "version": "1.0.0",
          "org.contentauth.c2pa_rs": "0.90.20"
        }
      ],
      "title": "생성된 이미지",
      "instance_id": "xmp.iid:813ee422-9736-4cdc-9be6-4e35ed8e41cb",
      "thumbnail": {
        "format": "image/jpeg",
        "identifier": "self#jumbf=/c2pa/urn:c2pa:debad325-ca4c-4d9d-8261-8fb85fa97cbe/c2pa.assertions/c2pa.thumbnail.claim"
      },
      "assertions": [
        {
          "label": "c2pa.actions.v2",
          "data": {
            "actions": [
              {
                "action": "c2pa.created",
                "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia"
              }
            ]
          }
        }
      ],

우리가 적어 넣지 않은 항목이 몇 개 늘어난 게 보이시나요? 도구가 자기 버전을 claim_generator_info에 덧붙였고, instance_id를 새로 발급했고, 썸네일 어서션까지 만들어 넣었습니다. 이 썸네일 때문에 파일이 꽤 무거워집니다.

결과
61720	sample/image.jpg
124737	signed.jpg

원본의 두 배가 됐습니다. 자격 증명은 어디까지나 파일에 덧붙이는 짐이라 이만한 비용이 듭니다. 뒤에서 볼 워터마크가 이런 짐 없이 콘텐츠 자체에 스며드는 것과 대조적이죠.

한 바이트만 바꿔도 들통납니다

이제 하드 바인딩이 일하는 모습을 볼 차례입니다. 서명된 이미지의 스캔 데이터 한복판에서 딱 한 바이트만 뒤집어 보겠습니다. 눈으로는 알아볼 수 없는 변화입니다.

tamper.py
b = bytearray(open("signed.jpg", "rb").read())
b[len(b) - 2000] ^= 0xFF  # 바이트 하나만 뒤집기
open("tampered.jpg", "wb").write(b)
./c2patool tampered.jpg
결과
validation_state: Invalid
 실패: signingCredential.untrusted / signing certificate untrusted
 실패: assertion.dataHash.mismatch / asset hash error, name: jumbf manifest, error: hash verification( Hashes do not match )

assertion.dataHash.mismatch가 뜨면서 검증이 무너졌습니다. 클레임 안에 박제된 해시와 지금 파일의 해시가 어긋났기 때문인데요. 서명이 클레임을 봉인하고 클레임이 콘텐츠를 해시로 붙잡고 있으니, 픽셀을 건드리는 순간 이 연결 고리가 끊어집니다. 반대로 말하면 검증에 통과한 파일은 서명된 이후로 단 한 바이트도 바뀌지 않았다는 뜻입니다.

서명이 유효한 것과 서명자를 믿는 것은 다릅니다

그런데 위 출력에 실패가 하나 더 있었습니다. signingCredential.untrusted인데요. 이건 변조와 무관합니다. 멀쩡한 signed.jpg를 검증해도 똑같이 나옵니다.

결과
validation_state: Valid
  [success] claimSignature.insideValidity
  [success] claimSignature.validated
  [success] assertion.hashedURI.match
  [success] assertion.hashedURI.match
  [success] assertion.hashedURI.match
  [success] assertion.dataHash.match
  [failure] signingCredential.untrusted

서명 자체는 유효하고 해시도 맞습니다. 다만 서명에 쓴 인증서가 신뢰 목록(trust list)에 없을 뿐입니다. 우리가 쓴 건 도구에 딸려 온 테스트 인증서니 당연한 결과죠.

이 구분이 C2PA를 이해하는 데 꽤 중요합니다. 검증기는 두 가지를 따로 따집니다. 먼저 이 매니페스트가 위조되지 않았는지 확인하고, 그다음에 서명한 주체를 믿을 만한지 묻습니다. C2PA는 인증 기관들의 목록을 따로 관리해서 두 번째 질문에 답하는데요. 누구나 자격 증명을 붙일 수 있지만 아무나 신뢰받지는 못한다는 뜻입니다. 이 지점은 Web Bot Auth에서 키 디렉터리가 하는 역할과 정확히 같은 고민입니다.

편집 이력은 사슬처럼 이어집니다

지금까지는 새로 만든 파일 하나만 다뤘는데요. 실제 작업은 만들고 끝나지 않습니다. 크기를 줄이고 색을 보정하고 잘라 내죠. C2PA는 이런 편집을 덮어쓰지 않고 차곡차곡 쌓아 올립니다.

기존 자산을 가져다 새 자산을 만들 때 가져온 쪽을 재료(ingredient)라고 부릅니다. 방금 서명한 이미지를 줄인 다음, 원본을 부모로 지정해 다시 서명해 보겠습니다.

sips -s format jpeg -Z 800 signed.jpg --out edited.jpg
./c2patool edited.jpg --manifest edit-manifest.json --parent signed.jpg --output chained.jpg --settings settings.toml
./c2patool chained.jpg --tree
결과
Tree View:
 Asset:chained.jpg, Manifest:urn:c2pa:9282784b-4bda-4ed6-96eb-0480b0fd5039
├── Assertion:c2pa.actions.v2
└── Asset:Untitled, Manifest:urn:c2pa:debad325-ca4c-4d9d-8261-8fb85fa97cbe
    └── Assertion:c2pa.actions.v2

파일 하나에 매니페스트가 둘 들어갔습니다. 바깥쪽은 방금 새긴 “크기를 줄였다”는 기록이고, 안쪽에 원래의 “AI가 생성했다”는 기록이 재료로 매달려 있습니다. 둘의 관계는 parentOf로 표시됩니다.

이 구조 덕분에 여러 단계를 거친 파일도 계보를 되짚을 수 있습니다. AI로 만든 그림을 편집자가 손보고 매체가 다시 잘라 내보냈다면, 마지막 파일 하나에서 그 세 단계가 모두 읽힙니다. 단계마다 서명한 주체가 다르니 어느 대목에서 무엇이 일어났는지도 갈라 볼 수 있고요. 무엇보다 원본이 AI 생성물이라는 사실이 편집을 몇 번 거쳐도 지워지지 않습니다.

다시 저장하면 통째로 사라집니다

여기까지는 꽤 견고해 보이는데요. 이제 약점을 볼 차례입니다. 서명된 파일을 그냥 다시 저장하면 어떻게 될까요? macOS에 기본으로 있는 sips로 포맷 변환 없이 재인코딩만 해보겠습니다.

sips -s format jpeg signed.jpg --out reencoded.jpg
./c2patool reencoded.jpg
결과
Error: No claim found

한 방에 사라졌습니다. 파일 크기도 124,737바이트에서 50,503바이트로 줄었는데요. 매니페스트가 통째로 날아갔기 때문입니다. 변조는 그렇게 정밀하게 잡아내면서, 정작 이미지 편집기로 한 번 열었다 저장하면 흔적도 없이 사라지죠.

방금 만든 계보도 함께 끊어집니다. 여러 단계를 쌓아 올린 사슬이든 한 겹짜리 기록이든 결과는 같습니다. 이게 C2PA의 근본적인 성격인데요. 자격 증명은 파일에 얹혀 있는 메타데이터라서, 그 메타데이터를 이해하지 못하는 도구를 한 번만 거쳐도 떨어져 나갑니다. 스크린샷을 다시 찍거나 메신저로 보내면서 재압축되는 경우도 마찬가지고요. 그래서 자격 증명이 없다는 사실은 아무것도 증명하지 못합니다. 애초에 안 붙은 것인지, 붙었다가 떨어진 것인지, 누가 일부러 지운 것인지 구분할 방법이 없으니까요.

Cr 배지는 무엇을 말해 줄까요

이제 도입부의 배지로 돌아가 봅시다. LinkedIn은 업로드된 이미지에서 우리가 방금 만든 것과 똑같은 매니페스트를 읽습니다. 그래서 배지를 눌렀을 때 나오는 항목이 앞의 출력과 그대로 겹칩니다. claim_generator_info에 적힌 도구 이름, 클레임 서명이 가리키는 서명자, 시각, 그리고 digitalSourceType이 말해 주는 AI 관여 여부입니다. 우리가 쓰는 쪽을 해봤다면 LinkedIn은 읽는 쪽을 맡은 셈이죠.

Cr은 Content Credentials를 짧게 자른 표기입니다. 두 단어의 머리글자를 딴 약어가 아니라서 CC가 아니고, 원소 기호처럼 첫 글자만 대문자로 씁니다. 아이콘이 핀 모양인 것도 뜻이 있는데요. 콘텐츠에 꽂아 둔 꼬리표라는 은유가 이 방식의 성격을 꽤 정확히 담고 있죠.

여기서 오해하기 쉬운 대목이 둘 있습니다.

먼저 배지가 붙었다고 해서 AI가 만든 이미지라는 뜻은 아닙니다. 배지는 출처 기록이 들어 있다는 표시일 뿐이라, 카메라로 찍고 편집기로 보정한 실사 사진에도 자격 증명이 있으면 똑같이 붙습니다. 안에 무엇이 적혀 있는지는 눌러 봐야 알 수 있죠. 배지는 기록의 존재를 알릴 뿐, 그 내용을 대신 요약해 주지 않습니다.

반대쪽 오해가 훨씬 위험합니다. 배지가 없다고 해서 AI가 만들지 않았다는 뜻은 전혀 아닙니다. 조금 전에 sips로 한 번 다시 저장했더니 Error: No claim found가 떴던 것을 떠올려 보세요. 화면을 캡처해서 올리거나 편집 앱을 한 번 거치기만 해도 기록은 통째로 떨어져 나갑니다. 누가 작정하고 지우지 않아도, 평범한 작업만으로 사라집니다.

그래서 이 표준이 쓸모를 가지려면 붙이는 쪽만큼 읽어 주는 쪽이 늘어야 합니다. 배지를 띄워 주는 플랫폼이 많아질수록 기록을 붙일 이유도 커지니까요. LinkedIn이 그 읽는 쪽에 먼저 선 사례입니다.

워터마크와 C2PA는 이렇게 나뉩니다

출처를 남기는 방법으로 워터마크를 떠올리셨을 수도 있겠습니다. 둘은 어떤 관계일까요? 재미있게도 C2PA 명세가 직접 답을 갖고 있습니다.

명세는 결속 방식을 두 갈래로 나눕니다. 하나는 앞에서 본 하드 바인딩이고, 다른 하나는 소프트 바인딩(soft binding)입니다. 소프트 바인딩은 “통계적으로 유일하지 않은 식별자이거나, 콘텐츠에 심긴 비가시 워터마크”라고 정의돼 있습니다. 워터마크가 C2PA 바깥의 경쟁 기술이 아니라 명세 안에 이미 자리를 잡고 있는 셈이죠.

둘의 성격은 정확히 반대입니다. 하드 바인딩은 정밀한 대신 깨지기 쉽습니다. 한 바이트만 달라져도 어긋나니 재인코딩을 견디지 못하죠. 반대로 소프트 바인딩은 느슨한 대신 질깁니다. 명세도 소프트 바인딩이 “바탕 비트가 달라져도 콘텐츠를 대조할 수 있게 해 준다”고 설명하는데요. 글에 심는 워터마크가 단어를 절반이나 바꿔 써도 흔적을 남기는 것이 바로 이 성질입니다. 그 원리는 AI가 쓴 글에 남는 워터마크에서 직접 구현해 가며 따로 다룹니다.

그래서 두 방식은 서로를 대체하지 않고 보완합니다. 자격 증명이 살아 있으면 누가 언제 무엇을 했는지 정확히 읽어 주고, 자격 증명이 떨어져 나간 뒤에는 워터마크가 흐릿하게나마 출처를 가리킵니다. 어느 한쪽만으로는 부족하다는 판단이 명세에 그대로 담겨 있는 것입니다.

마치며

지금까지 C2PA 자격 증명을 직접 서명하고 검증하고 깨뜨려 봤습니다. 정리하면 이렇습니다. 어서션에 사실을 적고, 클레임이 그것을 콘텐츠 해시와 함께 묶고, 개인 키로 봉인해서 파일에 넣는 구조입니다. 덕분에 한 바이트짜리 변조도 잡아내지만, 그 대가로 파일 크기가 늘고 재인코딩 한 번에 통째로 사라집니다.

그래서 자격 증명이 붙어 있다면 강력한 근거가 되지만, 없다고 해서 의심할 근거는 되지 못합니다. 이 비대칭을 이해하고 쓰는 것이 무엇보다 중요하겠습니다. 앞으로 카메라와 편집기, 그리고 플랫폼이 얼마나 이 기록을 지켜 주느냐에 따라 쓸모가 갈릴 텐데요. 지금은 아직 갈 길이 멀어 보입니다.

더 자세한 내용은 C2PA 명세를 참고하세요.

This work is licensed under CC BY 4.0CCBY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord