Minecraft Bedrock Pack Encryption
How Bedrock servers encrypt resource packs with AES-256-CFB8, and why anyone who can join can decrypt them.
Most featured Bedrock servers send their resource packs encrypted. Pull one out of the client and unzip it, and the textures and JSON are unreadable. This post covers how that encryption works and why anyone who can join the server can undo it, which is what my tool encrypUnlocker does.
What's in the pack
A pack is a ZIP. manifest.json is always plaintext, because the client needs the pack's UUID and version before it has a key. contents.json indexes every file:
{
"content": [
{ "path": "textures/blocks/stone.png", "key": "kX9f...32 characters...Qz" },
{ "path": "textures/ui/logo.png" },
{ "path": "sounds/mob/villager/hit1.ogg", "key": "Ab3d...32 characters...w7" }
]
}
A file with a key is encrypted with it. A file without one is stored as-is. contents.json itself is encrypted with the pack's content key, and that key isn't in the pack. The server sends it when you join.
Getting the key
Packs are negotiated during login, before you spawn. ResourcePacksInfo lists every pack with its UUID, version, size and ContentKey as a plain string. An empty ContentKey means the pack isn't encrypted.
The cipher
contents.json and every encrypted file use AES-256-CFB8:
- Key: the 32 ASCII bytes of the key string, with no hex or base64 decoding.
- IV: the first 16 bytes of the key.
CFB8 works one byte at a time. Encrypt a 16-byte register with AES, XOR the first output byte with the next ciphertext byte, then shift that ciphertext byte into the register. Output is the same length as the input.
func decryptCFB8(key, ciphertext []byte) []byte {
block, _ := aes.NewCipher(key)
reg := make([]byte, 16)
copy(reg, key[:16])
buf := make([]byte, 16)
out := make([]byte, len(ciphertext))
for i := range ciphertext {
block.Encrypt(buf, reg)
out[i] = ciphertext[i] ^ buf[0]
copy(reg, reg[1:])
reg[15] = ciphertext[i]
}
return out
}
If the little-endian uint32 at offset 4 of contents.json is 0x9BCFB9FC, the file starts with a 256-byte plaintext header holding the version and UUID. Skip it and decrypt the rest. Otherwise the whole file is ciphertext. The decrypted JSON ends in \x00 padding, so trim it before parsing.