mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a034d1f0b6 | ||
|
|
c248d3e1c4 |
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())
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
|
||||
Reference in new issue
Block a user