feat(train): the Jellyfin token in Train's own store, sealed by the Keystore (ADR 0030, D52)
The token moves from flutter_secure_storage into JellyfinTokens.kt: a file per person in the no-backup folder, sealed with AES-GCM under a key of Android's Keystore, the person's ID as associated data, so that the music job to come can read it as the app does. A token the keyring held is moved over as it is first read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
64366d5420
commit
6bde74f0ba
3 changed files with 342 additions and 18 deletions
|
|
@ -0,0 +1,99 @@
|
|||
package app.halkyon.train
|
||||
|
||||
import android.content.Context
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import java.security.KeyStore
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
|
||||
/**
|
||||
* The person's Jellyfin token, in one store of Train's (ADR 0030, D52), which the app reads over
|
||||
* the music channel and the music job reads directly: a file per person in [folder], sealed
|
||||
* with AES-GCM under [key] (a key Android's Keystore holds and never lets out), the person's ID
|
||||
* as its associated data, so that a file under another's name opens for no one. A file that
|
||||
* doesn't open (the key gone with a restore) is none, and deleted.
|
||||
*/
|
||||
class JellyfinTokens(
|
||||
private val folder: File,
|
||||
private val key: () -> SecretKey,
|
||||
private val warn: (String) -> Unit = { Log.w("Music", it) },
|
||||
) {
|
||||
fun read(person: String): String? {
|
||||
val file = fileOf(person)
|
||||
if (!file.exists()) return null
|
||||
return try {
|
||||
val bytes = file.readBytes()
|
||||
require(bytes.size > 1 + IV_BYTES && bytes[0] == VERSION)
|
||||
val cipher = Cipher.getInstance(CIPHER)
|
||||
cipher.init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(TAG_BITS, bytes, 1, IV_BYTES))
|
||||
cipher.updateAAD(person.toByteArray())
|
||||
String(cipher.doFinal(bytes, 1 + IV_BYTES, bytes.size - 1 - IV_BYTES), Charsets.UTF_8)
|
||||
} catch (error: Exception) {
|
||||
warn("The Jellyfin token not read: ${error.javaClass.simpleName}")
|
||||
file.delete()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** Keeps [token] for [person]; null forgets it. */
|
||||
fun keep(person: String, token: String?) {
|
||||
val file = fileOf(person)
|
||||
if (token == null) {
|
||||
file.delete()
|
||||
return
|
||||
}
|
||||
val cipher = Cipher.getInstance(CIPHER)
|
||||
// The Keystore makes the IV itself.
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key())
|
||||
cipher.updateAAD(person.toByteArray())
|
||||
val sealed = cipher.doFinal(token.toByteArray(Charsets.UTF_8))
|
||||
folder.mkdirs()
|
||||
val part = File(folder, "$person.part")
|
||||
part.writeBytes(byteArrayOf(VERSION) + cipher.iv + sealed)
|
||||
check(part.renameTo(file))
|
||||
}
|
||||
|
||||
/** Forgets everyone's token but [person]'s (one person's folder at a time). */
|
||||
fun forgetOthers(person: String) {
|
||||
fileOf(person)
|
||||
folder.listFiles()?.filter { it.name != person }?.forEach { it.delete() }
|
||||
}
|
||||
|
||||
private fun fileOf(person: String): File {
|
||||
require(Regex("[A-Za-z0-9-]+").matches(person)) { "Not a person's ID" }
|
||||
return File(folder, person)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val VERSION: Byte = 1
|
||||
private const val IV_BYTES = 12
|
||||
private const val TAG_BITS = 128
|
||||
private const val CIPHER = "AES/GCM/NoPadding"
|
||||
private const val ALIAS = "train_jellyfin"
|
||||
|
||||
fun of(context: Context) = JellyfinTokens(File(context.noBackupFilesDir, "jellyfin"), ::keystoreKey)
|
||||
|
||||
/** Train's key for the tokens in Android's Keystore, made once. */
|
||||
@Synchronized
|
||||
private fun keystoreKey(): SecretKey {
|
||||
val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||
(store.getKey(ALIAS, null) as SecretKey?)?.let { return it }
|
||||
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
|
||||
.apply {
|
||||
init(
|
||||
KeyGenParameterSpec.Builder(ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setKeySize(256)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
.generateKey()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -17,13 +17,15 @@ import java.util.concurrent.Executors
|
|||
|
||||
/**
|
||||
* Music's side (ADR 0030, D47), over `app.halkyon.train/music`: whether the network is metered,
|
||||
* as music is downloaded on unmetered ones only; and a downloaded track's tempo, the tag its
|
||||
* as music is downloaded on unmetered ones only; a downloaded track's tempo, the tag its
|
||||
* file names, else a minute from its middle decoded by Android's own decoder, mixed down to
|
||||
* mono and handed to [Tempo], one track at a time, off the main thread.
|
||||
* mono and handed to [Tempo], one track at a time, off the main thread; and the person's
|
||||
* Jellyfin token, in [JellyfinTokens] (D52).
|
||||
*/
|
||||
class MusicBridge(private val context: Context) : MethodChannel.MethodCallHandler {
|
||||
private val worker = Executors.newSingleThreadExecutor()
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
private val tokens by lazy { JellyfinTokens.of(context) }
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
|
|
@ -45,6 +47,15 @@ class MusicBridge(private val context: Context) : MethodChannel.MethodCallHandle
|
|||
main.post { result.success(tempo) }
|
||||
}
|
||||
}
|
||||
"tokenRead" -> result.success(tokens.read(call.argument<String>("person")!!))
|
||||
"tokenKeep" -> {
|
||||
tokens.keep(call.argument<String>("person")!!, call.argument<String>("token"))
|
||||
result.success(null)
|
||||
}
|
||||
"tokenForgetOthers" -> {
|
||||
tokens.forgetOthers(call.argument<String>("person")!!)
|
||||
result.success(null)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
package app.halkyon.train
|
||||
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import javax.crypto.KeyGenerator
|
||||
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.Test
|
||||
|
||||
// The person's Jellyfin token in one store of Train's (ADR 0030, D52): a
|
||||
// file per person, sealed with AES-GCM under a key the Keystore holds (a
|
||||
// software key here), unreadable without it, bound to its person.
|
||||
class JellyfinTokensTest {
|
||||
private val folder: File = Files.createTempDirectory("tokens").toFile()
|
||||
private val key = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
|
||||
private val tokens = JellyfinTokens(folder, { key }, warn = {})
|
||||
|
||||
@After fun tearDown() = folder.deleteRecursively().let {}
|
||||
|
||||
@Test
|
||||
fun `a token kept is read back, and its file never holds it in the clear`() {
|
||||
tokens.keep("person-1", "secret-token")
|
||||
|
||||
assertEquals("secret-token", tokens.read("person-1"))
|
||||
val bytes = File(folder, "person-1").readBytes()
|
||||
assertFalse(String(bytes, Charsets.ISO_8859_1).contains("secret-token"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `none kept is null, and null forgets it`() {
|
||||
assertNull(tokens.read("person-1"))
|
||||
tokens.keep("person-1", "secret-token")
|
||||
tokens.keep("person-1", null)
|
||||
|
||||
assertNull(tokens.read("person-1"))
|
||||
assertFalse(File(folder, "person-1").exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `another key reads nothing, and the file it can't open is deleted`() {
|
||||
tokens.keep("person-1", "secret-token")
|
||||
val other = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
|
||||
|
||||
assertNull(JellyfinTokens(folder, { other }, warn = {}).read("person-1"))
|
||||
assertFalse(File(folder, "person-1").exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one person's file moved to another's name is not theirs`() {
|
||||
tokens.keep("person-1", "secret-token")
|
||||
File(folder, "person-1").renameTo(File(folder, "person-2"))
|
||||
|
||||
assertNull(tokens.read("person-2"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the others' tokens are forgotten, the person's kept`() {
|
||||
tokens.keep("person-1", "one")
|
||||
tokens.keep("person-2", "two")
|
||||
tokens.forgetOthers("person-1")
|
||||
|
||||
assertEquals("one", tokens.read("person-1"))
|
||||
assertNull(tokens.read("person-2"))
|
||||
assertEquals(listOf("person-1"), folder.list()!!.toList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an ID that could name another file is refused`() {
|
||||
assertTrue(runCatching { tokens.keep("../escape", "x") }.isFailure)
|
||||
assertTrue(runCatching { tokens.read("a/b") }.isFailure)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue