Compare commits

...
Author SHA1 Message Date
The412Banner a034d1f0b6 Stores: keep a sealed sign-in through a transient Keystore failure
Only a file that can never open is deleted: bad GCM tag, a permanently
invalidated or missing key, or no envelope. Any other Keystore or I/O
failure keeps the file, leaves the store signed in but unavailable (its
actions say the sign-in could not be read), logs once and retries on the
next access.
2026-10-09 14:23:59 -04:00
The412Banner c248d3e1c4 Stores: seal sign-in files with an Android Keystore key
credentials.json for GOG, Epic and Amazon is an AES-256-GCM envelope
({"v":1,"alg":"AES/GCM","iv","ct"}) under a non-exportable AndroidKeyStore
key (StrongBox if present, else TEE, no user authentication). StoreAccounts
stays the one layer every reader and writer uses.

A plain file from an earlier build is sealed at start, verified, then
replaced. Without a Keystore the file stays plain (logged once, retried next
start); a file that no longer opens is deleted and the store reads as signed
out.
2026-10-09 14:14:46 -04:00
11 changed files with 462 additions and 16 deletions

No files matched your search

@@ -866,6 +866,8 @@ class MainActivity : ComponentActivity() {
SessionPrefs.setMicAsked(this)
}
if (wanted.isNotEmpty()) requestPermissions(wanted.toTypedArray(), 1)
// A store sign-in an earlier build left in plain text is sealed with the Keystore key.
Thread({ com.droiddeck.launcher.stores.StoreAccounts.encryptAll(this) }, "store-credentials").start()
// A session folder left without its ending - the process was killed - gets it now.
if (!SessionState.running) Thread({
com.droiddeck.launcher.session.LogMigration.run(this)
@@ -0,0 +1,105 @@
package com.droiddeck.launcher.stores
import android.os.Build
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import org.json.JSONObject
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
/**
* The stores' sign-in files at rest: AES-256-GCM under a key that lives in the AndroidKeyStore and
* never leaves it, as a small versioned envelope `{"v":1,"alg":"AES/GCM","iv":...,"ct":...}`
* (base64). The session's Linux side shares the app's files directory but not its Keystore, so
* what it can see of a sign-in is the envelope only.
*/
object CredentialCipher {
const val VERSION = 1
const val ALG = "AES/GCM"
private const val TRANSFORMATION = "AES/GCM/NoPadding"
private const val TAG_BITS = 128
/** Where the key comes from: the AndroidKeyStore on a device, a plain key in tests. */
fun interface KeyProvider {
/** The key, created on first use. Throws when no key can be had (no Keystore on this ROM). */
fun key(): SecretKey
/** The key that sealed earlier files: never a new one. Throws [KeyMissing] when there is none. */
fun existing(): SecretKey = key()
}
/** The Keystore answered and holds no key: nothing sealed before can open again. */
class KeyMissing : java.security.GeneralSecurityException("no key")
/**
* Whether [e], from [open], means the file can never open: it does not authenticate (tampered,
* or sealed under another key), the key is permanently invalidated or gone, or the file is not
* an envelope. Anything else - the Keystore busy or erroring, an I/O error - may pass.
*/
fun isPermanent(e: Throwable): Boolean = e is javax.crypto.AEADBadTagException ||
e is android.security.keystore.KeyPermanentlyInvalidatedException ||
e is KeyMissing || e is org.json.JSONException || e is IllegalArgumentException
/**
* An AES-256 key in the AndroidKeyStore: not exportable, encrypt/decrypt with GCM only, no user
* authentication (background downloads and launch-time code fetches use it with the screen
* off). StrongBox where the device has it, the TEE otherwise.
*/
object Keystore : KeyProvider {
private const val ALIAS = "droiddeck-store-credentials"
private const val PROVIDER = "AndroidKeyStore"
@Synchronized
override fun existing(): SecretKey {
val ks = KeyStore.getInstance(PROVIDER).apply { load(null) }
return ks.getKey(ALIAS, null) as? SecretKey ?: throw KeyMissing()
}
@Synchronized
override fun key(): SecretKey {
val ks = KeyStore.getInstance(PROVIDER).apply { load(null) }
(ks.getKey(ALIAS, null) as? SecretKey)?.let { return it }
if (Build.VERSION.SDK_INT >= 28) {
try { return generate(strongBox = true) } catch (e: Exception) { /* no StrongBox: the TEE */ }
}
return generate(strongBox = false)
}
private fun generate(strongBox: Boolean): SecretKey {
val spec = KeyGenParameterSpec.Builder(ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setKeySize(256)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setUserAuthenticationRequired(false)
.apply { if (strongBox && Build.VERSION.SDK_INT >= 28) setIsStrongBoxBacked(true) }
.build()
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, PROVIDER).run { init(spec); generateKey() }
}
}
/** [plain] sealed into the envelope. The cipher picks the IV (the Keystore requires it). */
fun seal(plain: String, keys: KeyProvider): JSONObject {
val cipher = Cipher.getInstance(TRANSFORMATION).apply { init(Cipher.ENCRYPT_MODE, keys.key()) }
val ct = cipher.doFinal(plain.toByteArray(Charsets.UTF_8))
return JSONObject().put("v", VERSION).put("alg", ALG)
.put("iv", b64(cipher.iv)).put("ct", b64(ct))
}
/** The text inside [envelope]. Throws when it is not ours, of another version, or does not authenticate. */
fun open(envelope: JSONObject, keys: KeyProvider): String {
require(envelope.optInt("v", -1) == VERSION && envelope.optString("alg") == ALG) { "unknown envelope" }
val iv = Base64.decode(envelope.getString("iv"), Base64.NO_WRAP)
val ct = Base64.decode(envelope.getString("ct"), Base64.NO_WRAP)
val cipher = Cipher.getInstance(TRANSFORMATION).apply { init(Cipher.DECRYPT_MODE, keys.existing(), GCMParameterSpec(TAG_BITS, iv)) }
return String(cipher.doFinal(ct), Charsets.UTF_8)
}
/** Whether [json] is an envelope rather than plain credentials. */
fun isEnvelope(json: JSONObject): Boolean = json.has("v") && json.has("ct") && json.has("iv")
private fun b64(bytes: ByteArray) = Base64.encodeToString(bytes, Base64.NO_WRAP)
}
@@ -2,6 +2,7 @@ package com.droiddeck.launcher.stores
import android.content.Context
import android.util.Log
import androidx.annotation.VisibleForTesting
import org.json.JSONObject
import java.io.File
@@ -9,40 +10,147 @@ import java.io.File
* Each store's sign-in, kept in the app's private files: `filesDir/stores/<store>/credentials.json`.
* Tokens never go to shared storage and never into a log; the file holds whatever the store's auth
* flow handed back (access and refresh tokens, expiry, the account's display name, and for Amazon
* the device registration), as one JSON object the store's own client reads and writes.
* the device registration), as one JSON object the store's own client reads and writes - sealed
* with [CredentialCipher] under a key in the AndroidKeyStore. Every read and write of a sign-in
* goes through here.
*
* - A plain file an earlier build wrote is sealed the first time it is read ([encryptAll] at app
* start), verified by opening it again, and the plain text is replaced.
* - Where the Keystore does not work (some ROMs), the file stays plain, the log says so once, and
* the next start tries again.
* - A sealed file that can never open again (the key is gone: data cleared, a backup restored on
* another device) is deleted and the store reads as signed out; one that fails for a reason that
* may pass is kept and tried again (see [read]).
*/
object StoreAccounts {
private const val TAG = "StoreAccounts"
private const val FILE = "credentials.json"
@VisibleForTesting
@Volatile
internal var keys: CredentialCipher.KeyProvider = CredentialCipher.Keystore
/** The Keystore failed once in this process: files stay plain until the next start tries again. */
@VisibleForTesting
@Volatile
internal var keystoreFailed = false
/** The store's private folder: credentials, caches, the engine's scratch. */
fun dir(context: Context, store: Store): File = File(File(context.filesDir, "stores"), store.id).apply { mkdirs() }
fun credentialsFile(context: Context, store: Store): File = File(dir(context, store), FILE)
/** The stored credentials, or null when signed out or unreadable (never logged: the text is the secret). */
/** Stores whose sealed sign-in could not be opened this time for a reason that may pass (see [read]). */
private val unavailable = java.util.concurrent.ConcurrentHashMap.newKeySet<Store>()
@Volatile
private var unavailableLogged = false
/**
* The stored credentials, or null when signed out or unreadable (never logged: the text is the
* secret).
*
* A sealed file is deleted - the store signs in again - only when it can never open: it does
* not authenticate (tampered, or another key: data cleared, restored on another device), the
* key is permanently invalidated or gone from the Keystore, or it is not an envelope at all. Any
* other failure (the Keystore busy or erroring, an I/O error) keeps the file, marks the store
* [isUnavailable] - signed in, its actions failing for now - and the next read tries again.
*/
@Synchronized
fun read(context: Context, store: Store): JSONObject? {
val f = credentialsFile(context, store)
if (!f.isFile) return null
return try { JSONObject(f.readText()) } catch (e: Exception) { Log.w(TAG, "${store.id}: credentials unreadable (${e.javaClass.simpleName})"); null }
if (!f.isFile) { unavailable.remove(store); return null }
val text = try { f.readText() } catch (e: Exception) { return markUnavailable(store, e) }
val json = try { JSONObject(text) } catch (e: Exception) { return unreadable(f, store, e) }
if (!CredentialCipher.isEnvelope(json)) {
unavailable.remove(store)
seal(f, json)?.let { Log.i(TAG, "stores: credentials encrypted ${store.id}") }
return json
}
return try {
JSONObject(CredentialCipher.open(json, keys)).also { unavailable.remove(store) }
} catch (e: Exception) {
if (CredentialCipher.isPermanent(e)) unreadable(f, store, e) else markUnavailable(store, e)
}
}
/** The store's sign-in is on disk but could not be opened this time (see [read]). */
fun isUnavailable(store: Store): Boolean = store in unavailable
private fun unreadable(f: File, store: Store, e: Exception): JSONObject? {
Log.w(TAG, "stores: credentials for ${store.id} cannot be opened (${e.javaClass.simpleName}); signed out")
f.delete()
unavailable.remove(store)
return null
}
private fun markUnavailable(store: Store, e: Exception): JSONObject? {
unavailable.add(store)
if (!unavailableLogged) {
unavailableLogged = true
Log.w(TAG, "stores: credentials unreadable this start (${e.javaClass.simpleName})")
}
return null
}
@Synchronized
fun write(context: Context, store: Store, json: JSONObject) {
unavailable.remove(store)
val f = credentialsFile(context, store)
val tmp = File(f.path + ".tmp")
tmp.writeText(json.toString())
tmp.setReadable(false, false); tmp.setReadable(true, true)
if (!tmp.renameTo(f)) { f.delete(); tmp.renameTo(f) }
if (seal(f, json) == null) put(f, json.toString())
}
@Synchronized
fun clear(context: Context, store: Store) {
unavailable.remove(store)
credentialsFile(context, store).delete()
File(credentialsFile(context, store).path + ".tmp").delete()
}
/** The account's display name when signed in (the store's own name for it, or a stand-in), else null. */
/** At app start: every plain sign-in an earlier build left is sealed now. */
fun encryptAll(context: Context) {
Store.entries.forEach { store ->
// An older build's temp file held the plain text too.
File(credentialsFile(context, store).path + ".tmp").delete()
read(context, store)
}
}
/**
* The account's display name when signed in (the store's own name for it, or a stand-in), else
* null. A sign-in that could not be opened this time ([isUnavailable]) still counts, under the
* store's name, so the sign-in card does not ask for what is still there.
*/
fun signedInAs(context: Context, store: Store): String? {
val json = read(context, store) ?: return null
val json = read(context, store) ?: return if (isUnavailable(store)) store.label else null
if (json.optString("access_token", "").isEmpty()) return null
return json.optString("display_name", "").ifEmpty { json.optString("username", "") }.ifEmpty { store.label }
}
/**
* [json] sealed into [f], checked by opening what was written. Null - and [f] as it was - when
* the Keystore cannot seal or the check fails.
*/
private fun seal(f: File, json: JSONObject): File? {
if (keystoreFailed) return null
val text = json.toString()
val envelope = try {
CredentialCipher.seal(text, keys).also { check(CredentialCipher.open(it, keys) == text) { "check failed" } }
} catch (e: Exception) {
keystoreFailed = true
Log.w(TAG, "stores: keystore unavailable, credentials stay plain (${e.javaClass.simpleName})")
return null
}
put(f, envelope.toString())
return f
}
/** [text] into [f] through a temp file and a rename, readable and writable by the app alone. */
private fun put(f: File, text: String) {
val tmp = File(f.path + ".tmp")
tmp.writeText(text)
tmp.setReadable(false, false); tmp.setReadable(true, true)
tmp.setWritable(false, false); tmp.setWritable(true, true)
if (!tmp.renameTo(f)) { f.delete(); tmp.renameTo(f) }
}
}
@@ -118,6 +118,11 @@ object StoresState {
fun isSignedIn(store: Store): Boolean = accounts.containsKey(store)
/** A store action's error line when it found no usable sign-in: gone, or on disk but unreadable for now. */
fun notSignedInLine(store: Store): String =
if (StoreAccounts.isUnavailable(store)) "${store.shortLabel} sign-in could not be read: try again."
else "${store.shortLabel} session expired: sign in again."
/** Stores whose sign-in has run out (a launch could not get a code): their chip dims until the user signs in again. */
val expired = mutableStateMapOf<Store, Boolean>()
@@ -55,7 +55,7 @@ object AmazonBackend : StoreBackend {
when (result) {
is AmazonLibrary.SyncResult.Ok -> { publish(app, result.games); StoresState.problems.remove(store) }
is AmazonLibrary.SyncResult.Failed -> StoresState.problems[store] = result.message
AmazonLibrary.SyncResult.NotLoggedIn -> StoresState.problems[store] = "Amazon session expired: sign in again."
AmazonLibrary.SyncResult.NotLoggedIn -> StoresState.problems[store] = StoresState.notSignedInLine(store)
AmazonLibrary.SyncResult.Throttled -> publish(app, AmazonLibrary.cached(app))
AmazonLibrary.SyncResult.Busy -> {}
}
@@ -6,6 +6,7 @@ import android.util.Log
import com.droiddeck.launcher.stores.CatalogItem
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreNet
import com.droiddeck.launcher.stores.StoresState
import org.json.JSONArray
import org.json.JSONObject
import java.util.concurrent.Callable
@@ -70,7 +71,7 @@ object AmazonLibrary {
}
onStatus("Checking sign-in…")
val creds = AmazonCredentialStore.load(app) ?: return SyncResult.NotLoggedIn
val token = AmazonCredentialStore.getValidAccessToken(app) ?: return SyncResult.Failed("Amazon session expired: sign in again.")
val token = AmazonCredentialStore.getValidAccessToken(app) ?: return SyncResult.Failed(StoresState.notSignedInLine(Store.AMAZON))
onStatus("Fetching game list…")
val all = AmazonApiClient.getEntitlements(token, creds.deviceSerial)
if (all.isNullOrEmpty()) return if (cachedList.isEmpty()) SyncResult.Failed("No games found in your Amazon library") else SyncResult.Ok(cachedList)
@@ -53,7 +53,7 @@ object EpicBackend : StoreBackend, EpicLaunchSupport {
StoresState.library[store] = com.droiddeck.launcher.stores.mergeItems(StoresState.library[store], result.games.map { EpicLibrary.toCatalogItem(app, it) }); StoresState.problems.remove(store)
}
is EpicLibrary.SyncResult.Failed -> StoresState.problems[store] = result.message
EpicLibrary.SyncResult.NotLoggedIn -> StoresState.problems[store] = "Epic session expired: sign in again."
EpicLibrary.SyncResult.NotLoggedIn -> StoresState.problems[store] = StoresState.notSignedInLine(store)
EpicLibrary.SyncResult.Throttled -> if (!StoresState.library.containsKey(store)) StoresState.library[store] = com.droiddeck.launcher.stores.mergeItems(StoresState.library[store], cached.map { EpicLibrary.toCatalogItem(app, it) })
EpicLibrary.SyncResult.Busy -> {}
}
@@ -42,7 +42,7 @@ object GogBackend : StoreBackend {
when (result) {
is GogLibrary.SyncResult.Ok -> { StoresState.library[store] = com.droiddeck.launcher.stores.mergeItems(StoresState.library[store], result.games.map { GogLibrary.toCatalogItem(app, it) }); StoresState.problems.remove(store) }
is GogLibrary.SyncResult.Failed -> StoresState.problems[store] = result.message
GogLibrary.SyncResult.NotLoggedIn -> StoresState.problems[store] = "GOG session expired: sign in again."
GogLibrary.SyncResult.NotLoggedIn -> StoresState.problems[store] = StoresState.notSignedInLine(store)
GogLibrary.SyncResult.Busy -> {}
}
}
@@ -0,0 +1,173 @@
package com.droiddeck.launcher.stores
import org.json.JSONObject
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import java.io.File
import java.security.SecureRandom
import javax.crypto.SecretKey
import javax.crypto.spec.SecretKeySpec
/** The stores' sign-in files sealed at rest; a plain key stands in for the AndroidKeyStore. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class StoreAccountsCipherTest {
private val app get() = RuntimeEnvironment.getApplication()
private fun newKey(): SecretKey = SecretKeySpec(ByteArray(32).also { SecureRandom().nextBytes(it) }, "AES")
private val key = newKey()
private val fake = CredentialCipher.KeyProvider { key }
private val broken = CredentialCipher.KeyProvider { throw java.security.KeyStoreException("no keystore") }
private val creds = JSONObject().put("access_token", "AT-secret-123456").put("refresh_token", "RT-secret-654321")
.put("account_id", "acct-1").put("display_name", "Someone").put("expires_at", 1_900_000_000_000L)
private fun file(store: Store) = StoreAccounts.credentialsFile(app, store)
@Before fun setUp() { StoreAccounts.keys = fake; StoreAccounts.keystoreFailed = false }
@After fun tearDown() {
StoreAccounts.keys = CredentialCipher.Keystore; StoreAccounts.keystoreFailed = false
for (store in Store.entries) StoreAccounts.clear(app, store)
}
@Test fun envelopeRoundTrips() {
val envelope = CredentialCipher.seal(creds.toString(), fake)
assertEquals(1, envelope.getInt("v"))
assertEquals("AES/GCM", envelope.getString("alg"))
assertFalse(envelope.toString().contains("secret"))
assertEquals(creds.toString(), CredentialCipher.open(envelope, fake))
// A fresh IV every time.
assertFalse(envelope.getString("iv") == CredentialCipher.seal(creds.toString(), fake).getString("iv"))
}
@Test fun writtenCredentialsAreSealedOnDisk() {
StoreAccounts.write(app, Store.EPIC, creds)
val text = file(Store.EPIC).readText()
assertFalse(text.contains("AT-secret") || text.contains("RT-secret") || text.contains("Someone"))
assertTrue(CredentialCipher.isEnvelope(JSONObject(text)))
assertEquals(creds.toString(), StoreAccounts.read(app, Store.EPIC).toString())
assertEquals("Someone", StoreAccounts.signedInAs(app, Store.EPIC))
assertFalse(File(file(Store.EPIC).path + ".tmp").exists())
}
@Test fun aPlainFileFromAnEarlierBuildIsSealedAndStaysSignedIn() {
for (store in Store.entries) file(store).writeText(creds.toString())
File(file(Store.GOG).path + ".tmp").writeText(creds.toString())
StoreAccounts.encryptAll(app)
for (store in Store.entries) {
val text = file(store).readText()
assertTrue(store.id, CredentialCipher.isEnvelope(JSONObject(text)))
assertFalse(store.id, text.contains("secret"))
assertEquals(store.id, creds.toString(), StoreAccounts.read(app, store).toString())
}
assertFalse(File(file(Store.GOG).path + ".tmp").exists())
}
@Test fun aTamperedFileReadsAsSignedOutAndIsDeleted() {
StoreAccounts.write(app, Store.GOG, creds)
val envelope = JSONObject(file(Store.GOG).readText())
val ct = android.util.Base64.decode(envelope.getString("ct"), android.util.Base64.NO_WRAP)
ct[0] = (ct[0].toInt() xor 1).toByte()
file(Store.GOG).writeText(envelope.put("ct", android.util.Base64.encodeToString(ct, android.util.Base64.NO_WRAP)).toString())
assertNull(StoreAccounts.read(app, Store.GOG))
assertNull(StoreAccounts.signedInAs(app, Store.GOG))
assertFalse(file(Store.GOG).exists())
}
@Test fun aFileSealedUnderALostKeyReadsAsSignedOutAndIsDeleted() {
StoreAccounts.write(app, Store.AMAZON, creds)
// Data cleared, or a backup restored on another device: the Keystore key is a new one.
val other = newKey()
StoreAccounts.keys = CredentialCipher.KeyProvider { other }
assertNull(StoreAccounts.read(app, Store.AMAZON))
assertFalse(file(Store.AMAZON).exists())
}
@Test fun garbageReadsAsSignedOut() {
file(Store.EPIC).writeText("{\"v\":1,\"alg\":\"AES/GCM\",\"iv\":\"!!\",\"ct\":\"??\"}")
assertNull(StoreAccounts.read(app, Store.EPIC))
file(Store.EPIC).writeText("not json")
assertNull(StoreAccounts.read(app, Store.EPIC))
}
@Test fun withoutAKeystoreCredentialsStayPlainAndTheNextStartSealsThem() {
StoreAccounts.keys = broken
StoreAccounts.write(app, Store.EPIC, creds)
assertEquals(creds.toString(), file(Store.EPIC).readText())
assertEquals(creds.toString(), StoreAccounts.read(app, Store.EPIC).toString())
assertTrue(StoreAccounts.keystoreFailed)
// The next start, on a Keystore that works.
StoreAccounts.keys = fake
StoreAccounts.keystoreFailed = false
StoreAccounts.encryptAll(app)
assertTrue(CredentialCipher.isEnvelope(JSONObject(file(Store.EPIC).readText())))
assertEquals(creds.toString(), StoreAccounts.read(app, Store.EPIC).toString())
}
@Test fun signOutRemovesTheFile() {
StoreAccounts.write(app, Store.GOG, creds)
StoreAccounts.clear(app, Store.GOG)
assertFalse(file(Store.GOG).exists())
assertNull(StoreAccounts.read(app, Store.GOG))
}
/** A Keystore whose key is there but which fails to hand it over [failures] times first. */
private class Flaky(private val key: SecretKey, var failures: Int, private val error: () -> Exception) : CredentialCipher.KeyProvider {
override fun key(): SecretKey = key
override fun existing(): SecretKey { if (failures > 0) { failures--; throw error() }; return key }
}
@Test fun aTransientKeystoreFailureKeepsTheFileAndALaterReadSucceeds() {
StoreAccounts.write(app, Store.EPIC, creds)
val sealed = file(Store.EPIC).readText()
for (error in listOf<() -> Exception>(
{ java.security.ProviderException("busy") },
{ java.security.KeyStoreException("keystore down") },
{ java.security.InvalidKeyException("not now") },
{ java.io.IOException("read") },
)) {
StoreAccounts.keys = Flaky(key, 1, error)
assertNull(StoreAccounts.read(app, Store.EPIC))
assertTrue(StoreAccounts.isUnavailable(Store.EPIC))
assertEquals(sealed, file(Store.EPIC).readText())
// Still signed in for the page: no sign-in card, and an action says why it failed.
assertEquals("Epic sign-in could not be read: try again.", StoresState.notSignedInLine(Store.EPIC))
// The next access, once the Keystore answers.
assertEquals(creds.toString(), StoreAccounts.read(app, Store.EPIC).toString())
assertFalse(StoreAccounts.isUnavailable(Store.EPIC))
assertEquals("Someone", StoreAccounts.signedInAs(app, Store.EPIC))
assertEquals("Epic session expired: sign in again.", StoresState.notSignedInLine(Store.EPIC))
}
}
@Test fun signedInAsKeepsTheStoreWhileItsSignInIsUnavailable() {
StoreAccounts.write(app, Store.GOG, creds)
StoreAccounts.keys = Flaky(key, 5) { java.security.ProviderException("busy") }
assertEquals("GOG", StoreAccounts.signedInAs(app, Store.GOG))
assertTrue(file(Store.GOG).exists())
}
@Test fun aKeyGoneFromTheKeystoreReadsAsSignedOutAndIsDeleted() {
StoreAccounts.write(app, Store.AMAZON, creds)
StoreAccounts.keys = object : CredentialCipher.KeyProvider {
override fun key(): SecretKey = newKey()
override fun existing(): SecretKey = throw CredentialCipher.KeyMissing()
}
assertNull(StoreAccounts.read(app, Store.AMAZON))
assertFalse(StoreAccounts.isUnavailable(Store.AMAZON))
assertFalse(file(Store.AMAZON).exists())
}
}
+34 -2
View File
@@ -189,5 +189,37 @@ Store log lines (the engines' included) go through `StoresState.logLine`, which
(`StoreLogFiles`: one file a day, seven days kept, ~2 MB each before it rolls to `.1`). Nothing
shows them in the app; the session's Share logs zip carries them under `stores/`.
Credentials live in `filesDir/stores/<store>/credentials.json` only (`StoreAccounts`); store URLs
are logged through `StoreLog.redactUrl`, which drops the query string where the signed tokens live.
Store URLs are logged through `StoreLog.redactUrl`, which drops the query string where the signed
tokens live.
## Security
- **Sign-ins:** `filesDir/stores/<store>/credentials.json` only, read and written through
`StoreAccounts` alone (`GogAuth`, `EpicCredentialStore`, `AmazonCredentialStore`, cloud saves
all call it). The file is an envelope `{"v":1,"alg":"AES/GCM","iv":...,"ct":...}` (base64):
AES-256-GCM (`CredentialCipher`) under a key generated in the AndroidKeyStore (alias
`droiddeck-store-credentials`; encrypt/decrypt, GCM, no padding, not exportable, no user
authentication so background downloads and launch-time exchange codes work with the screen off;
StrongBox where present, else the TEE). Written through a temp file and a rename, mode 600.
- **Earlier plain files:** sealed at app start (`StoreAccounts.encryptAll`, and on any read),
verified by opening the result, the plain file replaced; the log says
`stores: credentials encrypted <store>`. A plain `.tmp` left by an older build is deleted.
- **No Keystore** (some ROMs): the file stays plain, the log says
`stores: keystore unavailable, credentials stay plain (<exception class>)` once, and the next
start tries again.
- **A file that can never open** - it does not authenticate (`AEADBadTagException`: tampered, or
another key after data was cleared or a backup restored on another device), the key is
permanently invalidated or gone from the Keystore, or it is not an envelope: deleted, and the
store reads as signed out, so its sign-in card shows.
- **A failure that may pass** (`KeyStoreException`, `ProviderException`, any other
`InvalidKeyException`, an I/O error, StrongBox busy): the file is kept, the store stays signed in
(no sign-in card) and is marked unavailable (`StoreAccounts.isUnavailable`), a store action shows
`<Store> sign-in could not be read: try again.`, the log says
`stores: credentials unreadable this start (<exception class>)` once, and the next access tries
again.
- **The Linux session:** the app's files directory is bound into every session, so the guest can
see `credentials.json` - as the envelope only; the key never leaves the Keystore. Nothing in the
guest reads the files: `droiddeck-store-launch` asks the app over `<session>/stores/req|resp`
for an Epic exchange code, which the app writes beside the game's launcher; the launcher reads
it into a variable and deletes it before the game starts (single use, expires in minutes).
Steam's own client files are outside this.
+20
View File
@@ -7,6 +7,26 @@ the timeline, then lessons and backlog. Companion to the README (what the app *d
---
## 2026-10-09 - `feat/store-credentials-keystore`: store sign-ins sealed with the Android Keystore
- **What:** `filesDir/stores/<store>/credentials.json` (GOG, Epic, Amazon: tokens, ids, names,
expiry) is now an AES-256-GCM envelope (`{"v":1,"alg":"AES/GCM","iv","ct"}`) under a
non-exportable AndroidKeyStore key (StrongBox if present, else TEE; no user authentication).
`StoreAccounts` is the one layer; `CredentialCipher` holds the envelope and the key.
- **Upgrade:** a plain file is sealed at the first start (verified, then replaced); users stay
signed in. Log: `stores: credentials encrypted <store>`.
- **Fallbacks:** no Keystore - stays plain, logged once, retried next start. A file that can never
open (bad tag, key invalidated or missing, not an envelope) - deleted, signed out (sign-in card).
Any other Keystore or I/O failure - file kept, store stays signed in but unavailable (actions show
`<Store> sign-in could not be read: try again.`), logged once, retried on the next access. Never
a crash.
- **Session:** the guest sees the envelope only; it never read the files (the Epic code comes over
the request channel, written beside the launcher and deleted by it).
- **Tests:** `StoreAccountsCipherTest` (round trip, sealed on disk, migration of all three stores,
tampered file, lost key, key missing, garbage, no-Keystore fallback then sealed next start,
transient Keystore / I/O failures keep the file and a later read succeeds, sign-out). Not yet
on a device.
## 2026-10-08 - `feat/private-logs`: every log in app-private storage, shared as one scrubbed zip
- **Where logs live:** `files/logs/` (`LinuxRuntime.logDir`), nothing under `Download/DroidDeck`