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.
// 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
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.
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:
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:
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.