Skip to content

← Blog

Post

Minecraft Bedrock Pack Encryption

How Bedrock servers encrypt resource packs with AES-256-CFB8, and why anyone who can join can decrypt them.

2 min read8 viewsLikes: 0Dislikes: 0PGP signature

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.

Downloading and decrypting an encrypted packResourcePacksInfo: from server, UUID, version, ContentKey. ResourcePackClientResponse: from client, SendPacks + pack UUIDs. ResourcePackDataInfo: from server, size, chunk count, hash. ResourcePackChunkData: pack ZIP, one chunk at a time. Decrypt contents.json: skip 256-byte header, AES-256-CFB8 with ContentKey. Entry has a key? Decrypt with entry key. Copy as-is. Plain pack. ResourcePacksInfo to ResourcePackClientResponse. ResourcePackClientResponse to ResourcePackDataInfo. ResourcePackDataInfo to ResourcePackChunkData. ResourcePackChunkData to Decrypt contents.json. Decrypt contents.json to Entry has a key? Entry has a key? to Decrypt with entry key (yes). Entry has a key? to Copy as-is (no). Decrypt with entry key to Plain pack. Copy as-is to Plain pack.yesnoResourcePacksInfofrom serverUUID, version, ContentKeyResourcePackClientResponsefrom clientSendPacks + pack UUIDsResourcePackDataInfofrom serversize, chunk count, hashResourcePackChunkDatapack ZIP, one chunk at a timeDecrypt contents.jsonskip 256-byte headerAES-256-CFB8 with ContentKeyEntry has a key?Decrypt with entry keyCopy as-isPlain pack

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.