비밀과 표지의 역할
SnapSeal은 이미지, 파일, 텍스트의 원본 바이트와 메타데이터를 암호화한 뒤, 그 결과물인 암호화 봉투를 표지 이미지의 픽셀에 넣는다. 받는 사람에게는 보호된 파일과 비밀번호가 필요하다. 원래 표지나 서버는 필요하지 않다.
암호 기술은 내용의 기밀성을 지키고, 이미지 스테가노그래피는 데이터를 담을 운반체를 제공한다. 어느 쪽도 새로운 기술은 아니다. 여기서 중요한 것은 두 역할의 경계를 분명히 하는 일이다. 데모에 쓰인 그림과 테스트 비밀번호는 의도적으로 공개되어 있다.
original_bytes + metadataArgon2id → AES-GCMheader + ciphertext + tagRGB → PNG
password = "Public SnapSeal article example"하위 2비트에 담기
RGB 픽셀에는 각각 8비트인 채널이 3개 있다. 이 예제의 인코더는 채널을 왼쪽에서 오른쪽으로 읽되 알파 채널은 건너뛴다. 각 채널의 상위 6비트는 그대로 두고 하위 2비트만 바꾸므로, 채널 4개에 1바이트를 담을 수 있다.
슬라이더를 움직여 강조된 비트가 바뀌는 모습을 살펴보자. 이 하위 비트 쓰기만 따로 보면 채널 값의 변화는 최대 3이다. 선택적으로 추가하는 JPEG 복구 데이터나 다른 표지 장식까지 더하면 변화가 더 커질 수 있다.
R₀10101011170 → 171 Δ +1G₀10110101181 → 181 Δ 0B₀0101110094 → 92 Δ -2R₁11001011203 → 203 Δ 0def embed_byte(channels: bytes, value: int) -> bytes:
if len(channels) != 4 or not 0 <= value <= 255:
raise ValueError("Expected four channels and one byte")
pairs = [(value >> shift) & 3 for shift in (6, 4, 2, 0)]
return bytes((c & 0xFC) | pair
for c, pair in zip(channels, pairs, strict=True))
def read_byte(channels: bytes) -> int:
value = 0
for channel in channels:
value = (value << 2) | (channel & 3)
return value
cover = bytes([170, 181, 94, 203])
stored = embed_byte(cover, 0xD3)
assert stored == bytes([171, 181, 92, 203])
assert read_byte(stored) == 0xD3담을 수 있는 바이트 계산하기
채널 3개에 각각 2비트를 쓰면 픽셀당 6비트다. 1024×1024 표지에는 16바이트 픽셀 패킷 헤더를 넣기 전 786,432바이트, 헤더를 제외하면 786,416바이트를 담을 수 있다. 그림에 할당된 픽셀은 이 용량을 줄인다.
고정 표식은 패킷의 구조를 식별하고 길이 필드는 봉투의 끝을 알려준다. 패킷 버전과 암호화 버전은 서로 다른 체계다. 따라서 PNG2 패킷에 v3 암호화 봉투를 담을 수 있다.
C = floor(W × H × 3 × 2 / 8) − 16
C(1024, 1024) = 786,416 bytes[0:12] | SNAPSEALPNG2 | 12 bytes |
|---|---|---|
[12:16] | uint32_be(envelope_length) | 4 bytes |
[16:…] | envelope | envelope_length |
키를 유도하고 봉투를 인증하기
SnapSeal의 v3 프로필은 Argon2id v1.3으로 UTF-8 비밀번호와 무작위 16바이트 솔트에서 32바이트 키를 유도한다. 메모리는 64 MiB를 사용하며, 3회 반복하고 레인은 1개다. 리더는 키 유도 함수를 실행하기 전에 작업량 설정이 고정된 지원 값인지 검증해야 한다.
AES-256-GCM은 원본 바이트와 메타데이터를 암호화한다. 16바이트 태그는 암호문과 공개된 48바이트 헤더를 함께 인증한다. 인증에 실패하면 리더는 어떤 평문도 내보내서는 안 된다.
[0:8] | SNAPSEAL |
|---|---|
[8:12] | version=3 · KDF=1 · Argon2=0x13 (v1.3) · lanes=1 |
[12:16] | memory_KiB=65536 |
[16:20] | passes=3 |
[20:36] | salt · 16 bytes |
[36:48] | nonce · 12 bytes |
from argon2.low_level import Type, hash_secret_raw
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
def decrypt_v3(envelope: bytes, password: str) -> bytes:
if not 70 <= len(envelope) <= 50 * 1024 * 1024 + 4096:
raise ValueError("Invalid envelope size")
header = envelope[:48]
if header[:12] != b"SNAPSEAL\x03\x01\x13\x01":
raise ValueError("Expected the v3 Argon2id profile")
memory = int.from_bytes(header[12:16], "big")
passes = int.from_bytes(header[16:20], "big")
if (memory, passes) != (65536, 3):
raise ValueError("Unsupported work factors")
secret = password.encode("utf-8")
if not 1 <= len(password) <= 256 or len(secret) > 1024:
raise ValueError("Invalid password length")
key = hash_secret_raw(
secret, header[20:36], time_cost=passes,
memory_cost=memory, parallelism=1, hash_len=32,
type=Type.ID, version=19,
)
return AESGCM(key).decrypt(
header[36:48], envelope[48:], header,
)plaintext = uint32_be(metadata_length) + metadata_json + original_bytes
envelope = header + AESGCM(key).encrypt(nonce, plaintext, header)메시지를 담는 곳은 픽셀
페이로드는 PNG 메타데이터가 아니라 디코딩된 RGB 값에 들어 있다. PNG 파일의 바이트가 서로 달라도 디코딩된 RGB 값이 같다면 하위 비트 메시지는 보존된다. 반대로 스크린샷을 찍거나 픽셀을 편집하고 크기를 바꾸면 그림은 알아볼 수 있어도 데이터는 파괴될 수 있다.
원본 압축을 명시적으로 선택하지 않았다면 복원 시 원본 바이트와 메타데이터가 정확히 돌아온다. 파일명은 암호화되지만 패딩을 쓰지 않으면 봉투의 대략적인 크기는 드러난다.
PNG bytes A ≠ PNG bytes B
RGB(A) = RGB(B), α(A) = α(B) = 255
→ envelope(A) = envelope(B)압축 대응: JPEG를 통한 두 번째 복구 경로
압축 대응 기능은 크기가 그대로인 JPEG에서 복구를 시도하는 보조 경로다. 같은 암호화 봉투를 PNG의 RGB 하위 비트에 담고, 선택적으로 이미지의 주파수 계수에도 복구용 사본을 넣는다.
이 기능은 압축을 막거나 복구 성공을 보장하지 않는다. 형식을 탐지할 수 없게 만드는 것도 아니다.
envelope → RS(255,223)interleave → spread blocksQIM → luma DCT 8×8repair → CRC → AES-GCM
픽셀에서 주파수 계수로
JPEG는 8×8 휘도 블록에 DCT(이산 코사인 변환)를 적용해 주파수 계수로 바꾼 다음 양자화한다. SnapSeal은 선택한 계수에 데이터를 담을 때 값 사이에 의도적인 간격을 둔다. JPEG 인코딩으로 값이 다소 변해도 리더가 복구할 여지를 주기 위해서다.
오류 정정이 적용된 전송 헤더는 복구 데이터의 배치를 알려준다. 리더에 원래 표지는 필요하지 않다.
RGB → Y → DCT(8×8)
q = round(coefficient / JPEG_quantizer)
coefficient′ = q × JPEG_quantizer기호 사이에 여유 두기
QIM, 즉 양자화 인덱스 변조는 간격을 둔 위치에 기호를 대응시킨다. 1비트 설명 모델에서는 24의 배수가 0과 1을 번갈아 나타낸다. 1을 저장하려면 70을 72로 옮긴다. 72에서 +11만큼 변해도 1로 읽히지만, +13이면 경계를 넘어 비트가 뒤집힌다. 슬라이더를 움직여 그 경계를 확인해 보자.
실제 채널은 그레이 코딩을 사용해 선택한 계수마다 1~3비트를 저장한다. Python QIM 코드는 원리를 보여주는 모델이지 완전한 JPEG 디코더는 아니다.
from math import floor
def put_bit(coefficient: float, bit: int, step: int = 24) -> int:
cell = floor((coefficient / step - bit) / 2 + 0.5)
return step * (2 * cell + bit)
def read_bit(coefficient: float, step: int = 24) -> int:
return floor(coefficient / step + 0.5) % 2
stored = put_bit(70, 1) # 72
assert read_bit(stored + 11) == 1 # 83: same cell
assert read_bit(stored + 13) == 0 # 85: crossed the boundary먼저 정정하고 마지막에 인증하기
Reed–Solomon (255,223)은 데이터 223바이트에 패리티 32바이트를 추가해 코드워드당 최대 16개의 잘못된 바이트를 정정한다. 인터리빙과 이미지 전반에 흩어진 블록은 손상이 소수의 코드워드에 집중되지 않도록 돕는다.
리더는 전송 데이터를 정정하고 CRC를 검사한 뒤 AES-GCM으로 봉투를 인증한다. CRC는 전송 상태를 검증할 뿐 암호학적 인증은 아니다. 정정에 성공했더라도 GCM 검증에 실패하면 평문을 내보내서는 안 된다.
223 + 32 = 255 bytes
t = (255 − 223) / 2 = 16 byte errors
A₀ B₀ C₀ A₁ B₁ C₁ … → A, B, C
repair → CRC → AES-GCM → original_bytes두 사본을 합친 표지로 검사하기
두 사본이 같은 픽셀을 사용하므로 하위 비트 쓰기도 DCT 계수에 영향을 준다. 자체 검사는 두 사본을 모두 넣은 최종 표지로 수행한다. 로컬 브라우저 JPEG 검사에서 품질 0.60과 0.85→0.60 연속 인코딩 모두 암호화 봉투를 정확히 복구하고, 코드워드당 정정한 바이트가 최대 12개일 때만 복구 기능을 제공한다고 알린다. 12개는 정정 한도인 16개보다 적다. 이 품질 설정은 로컬 시험 조건이지 모든 JPEG에 대한 보장이 아니다.
자동 시도는 오버헤드를 포함한 암호화 데이터가 280,000바이트를 넘으면 중단한다. 중복 데이터와 더 강한 신호는 표지의 품질과 용량을 소비하지만 비공개 원본을 다시 압축하지는 않는다. 검사에 실패하면 무손실 PNG 또는 ZIP 전달로 전환한다. 자르기, 크기 변경, 회전이나 다른 인코더 때문에 복구가 실패할 수도 있으므로, 원본 PNG나 ZIP을 파일로 보내는 것이 가장 확실하다.
exact(PNG) ∧ exact(Q60) ∧ exact(Q85 → Q60)
max(corrected_bytes_per_codeword) ≤ 12 < 16
automatic: envelope_length ≤ 280_000형식이 드러내는 것
고정 표식 때문에 이 형식은 탐지할 수 있다. 표지 그림은 공개되어 있고 그림 자체로 인증되지 않는다. 상위 비트를 바꿔도 삽입된 메시지는 그대로 남을 수 있다. 오류 정정과 CRC는 전송 손상을 다루고, AES-GCM은 보호된 내용을 인증한다.
공격자는 여전히 오프라인에서 비밀번호를 추측할 수 있다. Argon2id가 추측 비용을 높이지만 약한 비밀번호를 안전하게 만들어주지는 못한다. 비밀번호 복구 기능은 없으며, 독립적인 보안 감사를 받았다는 주장도 아니다.
애플리케이션 없이 읽기
이 형식은 기존 암호 기술을 사용하고 데이터 구조와 한계를 명시한다. 독립적인 리더로도 읽을 수 있다. Python 3.12+ 리더는 JPEG 복구 사본이 들어 있는 경우를 포함해 원본 PNG를 읽는다. 크기 제한 등이 적용되는 단일 PNG SnapSeal ZIP과 원시 v3 봉투도 지원한다. JPEG로 변환된 파일을 읽으려면 TypeScript 코덱이 필요하다.
여기서 설명한 것은 오픈 소스 v3 구현이다. 기존에 배포된 오래된 리더는 그 출력물을 거부할 수 있다. 필요한 의존성은 가상 환경에 설치하면 된다. 전체 소스와 선택적 다운로드는 코드 예제 뒤에 나온다.
python -m pip install -r requirements.txtfrom getpass import getpass
from pathlib import Path
from decode_snapseal import decode_path
result = decode_path(
Path("protected.png"),
getpass("Password: "),
)
output = Path("restored")
output.mkdir(exist_ok=True)
with (output / result.filename).open("xb") as target:
target.write(result.data)

