Skip to content

Encrypted content

Seal a published project's bundle members with a key your game gets after sign-in, so bundles on a public CDN, in an app package or in the browser's cache are ciphertext to anyone without it.

module: com.awakekt.awake:project Desktop · Android · iOS · Web

Content bundles can be encrypted. Each distinct member is sealed on its own with AES-256-GCM, so range reads still fetch and check one member, and the same files and key give byte-identical bundles, so unchanged bundles keep their URLs. The engine checks each member as it reads it, and the ContentCache only ever holds sealed bytes. The decision record is D42.

What it protects

Encryption stops casual extraction: scraping a CDN, unpacking an app, reading bundles or the browser's IndexedDB, opening files in a generic viewer. It doesn't stop a player who holds the key, and nothing stops a frame debugger from reading decoded textures off the GPU. How strong it is depends only on where the key comes from:

Key delivery Strength
Online: your endpoint answers the key after sign-in Bundles on a public CDN are ciphertext to anyone not signed in.
Saved by the app after a first online sign-in As strong as the store the app keeps it in.
Fully offline: the key ships inside the client Deterrence only. Anyone who unpacks the client can recover the key.

Two limits hold whatever the key source:

  • Membership can be confirmed. Sealing is deterministic and each bundle keeps the plaintext SHA-256 of its members, so anyone who already has a file can confirm it is in your bundles, as the index already allows. Nobody learns a file they don't have.
  • A new key needs a new key id. Cached copies are kept by key id, so give every key its own id; a key rotated under the same id makes every cached copy fetch again.

Seal bundles when you publish

Give ContentBundleWriter a BundleEncryptionKey: 32 random bytes and an id that the index names. Read the key from a file or a CI secret. Never put it in a build script or the repository.

Kotlin
// 32 random bytes, read from a file or a CI secret, never from the repository.
val key = BundleEncryptionKey(keyId = "harbor-town-2026", bytes = keyFileBytes)
val writer = ContentBundleWriter(encryption = key)
val index = writer.write(looseIndex, read = { entry -> BundledProject.SAMPLE_FILES.getValue(entry.path) }) { bundle ->
    upload[bundle.url] = bundle.bytes
}
// index.encryption names "harbor-town-2026"; the key itself is in neither the index nor a bundle.

With the CLI or the Gradle task, name the key and say where to read it. A key file holds the 32 raw bytes or 64 hex digits; an environment variable holds the hex form:

awake bundle build/project.index.json --output build/published \
  --key-id harbor-town-2026 --key-file ~/.secrets/harbor.key   # or --key-env AWAKE_BUNDLE_KEY
build.gradle.kts
projectContent {
    bundleKeyId.set("harbor-town-2026")
    bundleKeyEnvironmentVariable.set("AWAKE_BUNDLE_KEY") // or bundleKeyFile.set(file(...)), outside the repository
}

generateContentBundles tracks only a SHA-256 of the key, so a new key reruns it and Gradle never stores the key. The CLI and the task write byte-identical bundles for the same key.

Make a key once per project or entitlement tier, for example with openssl rand 32 > bundle.key, and keep it in your secret store. Rotating a key re-seals every bundle and changes every URL, so don't rotate it per session. To revoke a player, stop answering them at your key or index endpoint.

With a public sign-in set, only the full set is sealed: generatePublicContentBundles never takes the key, because the sign-in scene loads before the player has it. Open the sealed set with your BundleKeyProvider after sign-in and attach it to the LayeredAssetSource; the key is asked for on the first read that needs it.

Online: fetch the key after sign-in

Pass a BundleKeyProvider to IndexedAssetSource or IndexedAssetSource.open. The engine asks it once per source for the index's key id. If the request fails, the read fails, and the next read asks again.

Kotlin
val online = BundleKeyProvider { keyId -> account.bundleKey(keyId) }
val files = IndexedAssetSource(index, fetch = { url -> upload.getValue(url) }, keys = online)

The key endpoint is yours. A minimal one in Ktor checks the session and answers the raw key:

Kotlin (Ktor server, an example only)
routing {
    authenticate("session") {
        get("/content-keys/{keyId}") {
            val keyId = call.parameters.getOrFail("keyId")
            val player = call.principal<PlayerSession>()!!
            if (!entitlements.canPlay(player, keyId)) return@get call.respond(HttpStatusCode.Forbidden)
            call.response.header(HttpHeaders.CacheControl, "no-store")
            call.respondBytes(secrets.bundleKey(keyId), ContentType.Application.OctetStream)
        }
    }
}

Serve it over HTTPS only, never cache it, and keep the bundles themselves on a public CDN with Cache-Control: public, max-age=31536000, immutable.

Offline play

BundleKeyProvider is the only key contract, and your app decides what it does. The same provider can answer a key the app saved after an earlier online sign-in, in a store of its choosing such as the Android Keystore, the iOS Keychain or a non-extractable WebCrypto key:

Kotlin
val keys = BundleKeyProvider { keyId ->
    store.load(keyId) ?: account.bundleKey(keyId).also { store.save(keyId, it) }
}

A build that must play with no network at all can return a key shipped in the client: BundleKeyProvider { embeddedKey }. This is deterrence only. The key is in the app package, so encryption slows an extractor down but doesn't stop them.

The cache

IndexedAssetSource puts each member's sealed bytes in the ContentCache, under a key that ProjectIndex.cacheKeyOf derives from the key id and the member's SHA-256. Every cache hit is decrypted and checked again. A hit that fails to decrypt isn't returned: the read fetches the member and replaces the cached copy once the fetched one decrypts, so offline the old copy stays. A FileSystemContentCache or the browser's IndexedDB cache therefore holds nothing readable, and a signed-out player can't read it. A cache of your own must prune by ProjectIndex.cacheKeyOf, not by the entry's sha256.

Format

Bundles. An encrypted bundle is format version 2: the header's reserved field holds flags, and bit 0 (members encrypted) is always set. Each table record's size is the sealed size, the plaintext's size plus a 16-byte tag, and its SHA-256 is the plaintext's. Each member's AES key and nonce are the 44 bytes of HKDF-SHA256(key, salt = keyId, info = "awake-awkb-v2" 0x00 sha256). An engine that reads only version 1 refuses a version 2 bundle with a version error.

The index. An encrypted index is formatVersion 4. It adds "encryption": { "keyId": "…", "scheme": "aes-256-gcm-hkdf-sha256" }, and every entry is a bundle member. An engine that reads only versions 2 and 3 refuses it.

See also