01

Separe o segredo da imagem de capa

O SnapSeal pode proteger imagens, arquivos ou texto. Seu codec criptografa os bytes originais e seus metadados, depois incorpora o envelope resultante em uma imagem de capa. Para decodificar o arquivo protegido, o destinatário precisa da senha, mas não de rede, servidor ou imagem de capa original.

A criptografia preserva a confidencialidade; a esteganografia em imagens fornece o meio de transporte. Nenhuma das duas é uma técnica nova. A demonstração pública usa uma imagem e uma senha de teste publicadas intencionalmente.

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

Use os dois bits menos significativos

Cada pixel 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. Assim, quatro canais carregam um byte.

Mova o controle para observar a mudança nos bits destacados. Isoladamente, essa gravação altera o valor de um canal em no máximo 3; a cópia opcional para recuperação JPEG 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

Calcule a capacidade

Três canais com dois bits cada fornecem seis bits por pixel. Uma capa de 1024×1024 comporta 786.432 bytes antes do cabeçalho de 16 bytes do pacote de pixels, restando 786.416 bytes depois dele. Elementos gráficos reservam pixels e reduzem essa capacidade.

Um marcador fixo identifica a estrutura do pacote, e um campo de comprimento delimita o envelope. As versões do pacote e da criptografia são independentes: um pacote PNG2 pode carregar um envelope 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

Derive a chave e autentique o envelope

No perfil v3 do SnapSeal, o Argon2id v1.3 deriva uma chave de 32 bytes a partir da senha 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 a KDF, o leitor deve verificar se os parâmetros fixos de custo são os suportados.

O AES-256-GCM criptografa os bytes originais e os metadados. Sua tag de 16 bytes autentica o conteúdo cifrado e o cabeçalho público de 48 bytes. Se a autenticação falhar, o leitor nunca deve liberar o conteúdo em texto 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 pixels

Os dados ficam nos valores RGB decodificados, não nos metadados do PNG. Dois arquivos PNG podem ter bytes diferentes e ainda preservar a mesma mensagem nos bits inferiores, desde que seus valores RGB decodificados permaneçam iguais. Já uma captura de tela, uma edição de pixels ou um redimensionamento pode destruir os dados sem tornar a imagem irreconhecível.

A menos que a compressão da origem tenha sido selecionada explicitamente, a recuperação restaura os bytes originais e os metadados exatamente. O nome do arquivo é criptografado, mas, sem preenchimento, o envelope ainda revela 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: uma segunda via pelo JPEG

A anticompressão é uma tentativa de recuperar o conteúdo de JPEGs que mantenham as mesmas dimensões. O mesmo envelope criptografado tem uma cópia sem perdas nos bits inferiores RGB do PNG e, opcionalmente, uma cópia de recuperação nos coeficientes de frequência da imagem.

Isso não impede a compressão nem promete sobrevivência à passagem por qualquer mensageiro. Também não torna o formato indetectável.

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

Dos pixels às frequências

O JPEG aplica a transformada discreta de cosseno (DCT) a blocos de luminância de 8×8. Assim, obtém coeficientes de frequência que depois são quantizados. O SnapSeal usa coeficientes selecionados para carregar dados em valores deliberadamente espaçados, deixando margem para algumas alterações causadas pela codificação JPEG.

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

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

Deixe espaço entre os símbolos

A 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 armazenar 1, o codificador desloca 70 para 72. Uma alteração de +11 a partir de 72 ainda é decodificada como 1; com +13, o limite é cruzado e o bit se inverte. Mova o controle para ver onde isso acontece.

O canal real armazena de um a três bits por coeficiente selecionado, usando código Gray. O trecho de QIM em Python ilustra o princípio; não é um decodificador 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

Repare primeiro, autentique depois

O Reed–Solomon (255,223) acrescenta 32 bytes de paridade a 223 bytes de dados e corrige até 16 bytes incorretos por palavra de código. O entrelaçamento e a distribuição dos blocos pela imagem ajudam a espalhar os danos, em vez de concentrá-los em poucas palavras de código.

O leitor repara o transporte, verifica seu CRC e então autentica o envelope com AES-GCM. O CRC valida o transporte; não é uma autenticação criptográfica. Mesmo após um reparo bem-sucedido, o texto claro não pode ser liberado 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

Teste as duas cópias juntas

As duas cópias ocupam os mesmos pixels, de modo que a gravação nos bits inferiores também perturba os coeficientes DCT. Por isso, os testes usam a imagem final com os dois canais. A recuperação só é anunciada quando o envelope é recuperado exatamente nos testes locais de JPEG do navegador, com qualidade 0,60 e em uma codificação repetida 0,85→0,60, com no máximo 12 bytes reparados por palavra de código — abaixo do limite de correção de 16 bytes. Esses ajustes são testes locais, não garantias universais para JPEG.

As tentativas automáticas param em 280.000 bytes criptografados, incluindo a sobrecarga do formato. Mais redundância e sinais mais fortes consomem qualidade e capacidade da capa; esse canal não recomprime o original privado. Se os testes falharem, a entrega recorre a PNG ou ZIP sem perdas. Recorte, redimensionamento, rotação e outros codificadores podem impedir a recuperação. A recomendação é enviar o PNG ou ZIP original como arquivo.

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

Saiba o que o formato expõe

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

Ainda é possível tentar adivinhar senhas offline. O Argon2id encarece cada tentativa, mas não salva uma senha fraca. Não há recuperação de senha, nem alegação de auditoria de segurança independente.

08

Leia o arquivo sem o aplicativo

O leitor para download em Python 3.12+ aceita PNGs originais, inclusive os que têm uma cópia para recuperação JPEG, ZIPs SnapSeal sujeitos a limites e com um único PNG, e envelopes v3 brutos. Arquivos JPEG convertidos exigem o codec em TypeScript.

O formato descrito aqui corresponde à implementação v3 de código aberto; leitores antigos já publicados podem rejeitar seus arquivos. Instale as dependências indicadas em um ambiente virtual. O código completo e os downloads opcionais vêm 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