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.
- 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
Key derivation
| Parameter | Value |
|---|---|
| KDF | PBKDF2 (RFC 8018) |
| PRF | HMAC-SHA-256 |
| Iterations | 100 000 |
| Salt | 16 bytes, fresh per encryption from a CSPRNG |
| Password encoding | UTF-8, no normalisation |
| Derived key | 256 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)))
}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-requiredand 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.