FWC-1§6Normative

Encryption

An optional password envelope: PBKDF2-SHA-256 key derivation and AES-256-GCM. The favicon is public by nature, so the envelope must withstand offline attack.

Envelope layout

When bit 0 of flags (FLAG_ENCRYPTED, 0b01) is set, the body is the following structure. The length and crc32 header fields cover the entire envelope.

salt0–15
iv16–27
ciphertext ‖ tag28…
00
salt · 16 B
Random PBKDF2 salt
16
iv · 12 B
Random AES-GCM nonce
28
ciphertext ‖ tag · n B
AES-256-GCM output, 16-byte tag appended
Encrypted body envelope · 28 fixed bytes

Key derivation

ParameterValue
KDFPBKDF2 (RFC 8018)
PRFHMAC-SHA-256
Iterations100 000
Salt16 bytes, fresh per encryption from a CSPRNG
Password encodingUTF-8, no normalisation
Derived key256 bits, AES-GCM, usage encrypt or decrypt only

The iteration count is fixed by this version rather than stored in the envelope. That keeps the header small and removes an attacker-controlled parameter (a malicious container cannot demand a billion iterations to freeze the decoder). Raising the count requires a new flag bit or format version.

Cipher

AES-256 in Galois/Counter Mode with a 96-bit nonce and the default 128-bit authentication tag, as exposed by WebCrypto { name: "AES-GCM", iv }. No additional authenticated data is used. Because the salt and IV are both freshly random for every encryption, encrypting the same payload twice yields unrelated envelopes.

async function deriveKey(password: string, salt: Uint8Array, usage: 'encrypt' | 'decrypt') {
  const material = await crypto.subtle.importKey(
    'raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveKey'])
  return crypto.subtle.deriveKey(
    { name: 'PBKDF2', salt, iterations: 100_000, hash: 'SHA-256' },
    material, { name: 'AES-GCM', length: 256 }, false, [usage])
}

export async function encrypt(plain: Uint8Array, password: string) {
  const salt = crypto.getRandomValues(new Uint8Array(16))
  const iv = crypto.getRandomValues(new Uint8Array(12))
  const key = await deriveKey(password, salt, 'encrypt')
  const cipher = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plain))
  const out = new Uint8Array(28 + cipher.length)
  out.set(salt, 0); out.set(iv, 16); out.set(cipher, 28)
  return out
}

export async function decrypt(envelope: Uint8Array, password: string) {
  const key = await deriveKey(password, envelope.slice(0, 16), 'decrypt')
  return new Uint8Array(await crypto.subtle.decrypt(
    { name: 'AES-GCM', iv: envelope.slice(16, 28) }, key, envelope.slice(28)))
}
Reference encrypt / decrypt

Decoder behaviour

  • Decoders MUST verify the CRC before prompting for a password, so a corrupted icon is reported as corruption and never as "wrong password".
  • A GCM tag failure MUST be surfaced as bad-password. It is indistinguishable from tampering by design, and the message SHOULD not attempt to distinguish the two.
  • If no password is available (user cancelled, or a non-interactive context), decoders MUST stop with password-required and MUST NOT render anything from the body.
  • Decoders SHOULD NOT cache derived keys or passwords beyond the current page load.

Size overhead

Encryption adds exactly 44 bytes: 16 (salt) + 12 (IV) + 16 (tag). Combined with the 12-byte header, an encrypted container costs 56 bytes over the compressed payload. In a 16×16 noise icon that leaves 700 bytes for content.