01

O segredo e a imagem de capa

O SnapSeal começa pelos bytes originais de imagens, ficheiros ou texto. Cifra esses bytes e os respetivos metadados, inserindo depois o invólucro resultante nos píxeis de uma imagem de capa. Para descodificar o ficheiro protegido, o destinatário precisa da palavra-passe, mas não de um servidor nem da capa original.

A criptografia protege a confidencialidade; a esteganografia fornece o suporte. Ambas são técnicas estabelecidas. Neste exemplo, a ilustração e a palavra-passe de teste são intencionalmente públicas.

  1. original_bytes + metadata
  2. Argon2id → AES-GCM
  3. header + ciphertext + tag
  4. RGB → PNG
password = "Public SnapSeal article example"
GitHub · snapseal-codec
02

Os dois bits menos significativos

Cada píxel RGB tem três canais de 8 bits. Neste exemplo, o codificador percorre os canais da esquerda para a direita, ignora o canal alfa, preserva os seis bits superiores e substitui os dois inferiores. Quatro canais podem, assim, transportar um byte.

Mova o cursor para ver os bits destacados mudar. Esta escrita isolada nos bits inferiores altera o valor de um canal, no máximo, em 3; o suporte JPEG opcional e outros tratamentos visuais podem alterar mais a capa.

R₀10101011170 → 171 Δ +1
G₀10110101181 → 181 Δ 0
B₀0101110094 → 92 Δ -2
R₁11001011203 → 203 Δ 0
def 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
03

Quantos bytes cabem na imagem?

Três canais com dois bits cada dão seis bits por píxel. Uma capa de 1024×1024 comporta 786 432 bytes antes do cabeçalho de 16 bytes do pacote de píxeis, restando 786 416 bytes depois dele. A ilustração reserva píxeis e reduz essa capacidade.

Um marcador fixo identifica a estrutura do pacote e um campo de comprimento assinala o fim do invólucro. As versões do pacote e da cifra são independentes: um pacote PNG2 pode transportar um invólucro criptográfico v3.

C = floor(W × H × 3 × 2 / 8) − 16
C(1024, 1024) = 786,416 bytes
SNAPSEALPNG2
[0:12]SNAPSEALPNG212 bytes
[12:16]uint32_be(envelope_length)4 bytes
[16:…]envelopeenvelope_length
pixel-packet.tsstyled-packet.ts
04

Derivar a chave e autenticar o invólucro

O perfil v3 do SnapSeal usa Argon2id v1.3 para derivar uma chave de 32 bytes a partir da palavra-passe em UTF-8 e de um salt aleatório de 16 bytes. A derivação usa 64 MiB de memória, três passagens e uma via. Antes de executar o KDF, o leitor tem de validar os parâmetros de trabalho fixos suportados.

AES-256-GCM cifra os bytes originais e os metadados. A etiqueta de autenticação de 16 bytes protege o conteúdo cifrado e o cabeçalho público de 48 bytes. Se a autenticação falhar, o leitor nunca deve disponibilizar o texto em claro.

SNAPSEAL v3 · 48 bytes
[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)
RFC 9106 · Argon2AESGCM · cryptography
05

A mensagem está nos píxeis

Os dados estão nos valores RGB descodificados, não nos metadados PNG. Dois ficheiros PNG podem ter bytes diferentes e conservar a mesma mensagem nos bits inferiores, desde que os valores RGB descodificados não mudem. Em contrapartida, uma captura de ecrã, uma edição de píxeis ou um redimensionamento podem destruir os dados, mesmo que a imagem continue reconhecível.

Salvo se tiver sido escolhida explicitamente a compressão da origem, a recuperação repõe exatamente os bytes originais e os metadados. O nome do ficheiro é cifrado, mas, sem preenchimento, o invólucro continua a revelar o tamanho aproximado.

PNG bytes A ≠ PNG bytes B
RGB(A) = RGB(B), α(A) = α(B) = 255
→ envelope(A) = envelope(B)
PNG · W3C
06

Anticompressão: outra via de recuperação em JPEG

A anticompressão procura recuperar dados de ficheiros JPEG que mantenham as mesmas dimensões, sem garantia de sucesso. O mesmo invólucro cifrado tem uma cópia nos bits inferiores RGB do PNG e pode ter outra nos coeficientes de frequência da imagem.

Esta via adicional não impede a compressão nem torna o formato indetetável.

  1. envelope → RS(255,223)
  2. interleave → spread blocks
  3. QIM → luma DCT 8×8
  4. repair → CRC → AES-GCM

Dos píxeis às frequências

O JPEG aplica a transformada discreta do cosseno (DCT) a blocos de luminância de 8×8, produzindo coeficientes de frequência que são depois quantizados. O SnapSeal usa coeficientes selecionados para transportar dados em valores deliberadamente espaçados, dando ao leitor alguma margem para tolerar alterações causadas pela codificação JPEG.

Um cabeçalho de transporte protegido por correção de erros identifica a disposição dos dados de recuperação. Não autentica a ilustração pública da capa, e o leitor não precisa da capa original.

RGB → Y → DCT(8×8)
q = round(coefficient / JPEG_quantizer)
coefficient′ = q × JPEG_quantizer

Espaço entre símbolos

QIM, ou modulação por índice de quantização, associa símbolos a posições espaçadas. No modelo didático de um bit, os múltiplos de 24 alternam entre 0 e 1. Para guardar 1, o codificador desloca 70 para 72. Uma alteração de +11 a partir de 72 ainda é descodificada como 1; com +13, atravessa-se o limite e o bit muda. Mova o cursor para ver onde isso acontece.

O canal real guarda entre um e três bits por coeficiente selecionado, usando código Gray. O excerto Python de QIM ilustra o princípio; não é um descodificador JPEG completo.

c′ = 72 + 0 = 72 b′ = ⌊c′ / 24 + ½⌋ mod 2 = 1 = b
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

Reparar primeiro, autenticar depois

Reed–Solomon (255,223) acrescenta 32 bytes de paridade a 223 bytes de dados e consegue corrigir até 16 bytes errados por palavra de código. O entrelaçamento e a dispersão dos blocos pela imagem ajudam a distribuir os danos, em vez de os concentrar em poucas palavras de código.

O leitor repara o transporte, verifica o CRC e só depois autentica o invólucro com AES-GCM. O CRC valida o transporte; não é autenticação criptográfica. Uma reparação bem-sucedida não autoriza a disponibilização de texto em claro se a verificação GCM falhar.

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

Testar o suporte combinado

As duas cópias ocupam os mesmos píxeis, pelo que a escrita nos bits inferiores também perturba os coeficientes DCT. Os testes automáticos usam, por isso, o suporte final combinado. A recuperação só é anunciada quando o invólucro é recuperado exatamente em testes locais de JPEG no navegador, à qualidade 0.60 e numa codificação repetida 0.85→0.60, com um máximo de 12 bytes reparados por palavra de código — abaixo do limite de correção de 16. Estes são testes locais, não garantias para todos os codificadores JPEG.

As tentativas automáticas param nos 280 000 bytes cifrados, incluindo a sobrecarga. A redundância e os sinais mais fortes consomem qualidade e capacidade da capa; não recomprimem o original privado. Se os testes falharem, a entrega recorre a PNG ou ZIP sem perdas. Recortes, redimensionamento, rotação ou outros codificadores podem impedir a recuperação, pelo que a recomendação fiável é enviar o PNG ou ZIP original como ficheiro.

exact(PNG) ∧ exact(Q60) ∧ exact(Q85 → Q60)
max(corrected_bytes_per_codeword) ≤ 12 < 16
automatic: envelope_length ≤ 280_000
jpeg-channel.tspng-container.tsJPEG · ITU-T T.81QIM · Chen & Wornell (2001)
07

O que o formato deixa exposto

Os marcadores fixos tornam o formato detetável. A ilustração da capa é pública e não é autenticada como imagem; alterações nos seus bits superiores podem deixar a mensagem incorporada intacta. A correção de erros e o CRC tratam danos no transporte, enquanto AES-GCM autentica o conteúdo protegido.

Um atacante continua a poder testar palavras-passe sem ligação à rede. Argon2id encarece cada tentativa, mas não salva uma palavra-passe fraca. Não há recuperação de palavra-passe, nem se afirma que tenha sido realizada uma auditoria de segurança independente.

08

Ler os dados sem a aplicação

O formato combina primitivas existentes, limites explícitos e um leitor independente. Esse leitor, em Python 3.12+, suporta PNG originais, incluindo os que têm uma cópia de recuperação JPEG, ficheiros ZIP SnapSeal sujeitos a limites e com um único PNG, e invólucros v3 em bruto. Ficheiros convertidos para JPEG exigem o codec TypeScript.

A versão aqui descrita é a implementação v3 de código aberto; leitores antigos já em utilização podem rejeitar os ficheiros que produz. Instale as dependências indicadas num ambiente virtual. O código completo e as transferências opcionais encontram-se depois dos exemplos.

python -m pip install -r requirements.txt
from 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)
GitHub · decode_snapseal.pyFORMAT.md
↓ decode_snapseal.py↓ requirements.txt