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:
Tizian 「ティツィアーン」 2026-10-29 18:42:46 +01:00
commit 6bde74f0ba
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
7 changed files with 342 additions and 18 deletions

File diff suppressed because one or more lines are too long

View file

@ -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()
}
}
}

View file

@ -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()
}
}

View file

@ -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)
}
}

View file

@ -426,7 +426,7 @@ Future<TrainPerson> _openPersonData(
final music = Platform.isAndroid
? MusicLibrary(
MusicStore(File(p.join(folder.path, 'music.json'))),
SecureJellyfinTokenStore(person.authUserId),
AndroidJellyfinTokenStore(person.authUserId),
version: appVersion,
)
: null;

View file

@ -4,6 +4,7 @@ import 'dart:io';
import 'dart:math' show Random;
import 'package:equatable/equatable.dart';
import 'package:flutter/services.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// Music from Jellyfin on this phone, for one person (ADR 0030, D47): never
@ -137,32 +138,50 @@ abstract interface class JellyfinTokenStore() {
Future<void> forgetOthers();
}
/// [JellyfinTokenStore] in the system's keyring (Android's Keystore), under
/// `train_jellyfin:` and the person's ID.
class SecureJellyfinTokenStore(
/// [JellyfinTokenStore] in Train's own store on Android (ADR 0030, D52),
/// over `app.halkyon.train/music` (`JellyfinTokens.kt`: sealed under a key
/// of the Keystore's, where the music job reads it too). A token the system's
/// keyring kept before, under `train_jellyfin:` and the person's ID, is moved
/// over as it is first read, and leaves the keyring.
class AndroidJellyfinTokenStore(
final String _person, {
final FlutterSecureStorage _storage = const FlutterSecureStorage(),
final MethodChannel _channel = const MethodChannel('app.halkyon.train/music'),
final FlutterSecureStorage _keyring = const FlutterSecureStorage(),
}) implements JellyfinTokenStore {
static const _prefix = 'train_jellyfin:';
static const _keyringPrefix = 'train_jellyfin:';
String get _key => '$_prefix$_person';
String get _keyringKey => '$_keyringPrefix$_person';
@override
Future<String?> read() => _storage.read(key: _key);
Future<String?> read() async {
final token = await _channel.invokeMethod<String>('tokenRead', {
'person': _person,
});
if (token != null) {
return token;
}
final before = await _keyring.read(key: _keyringKey);
if (before != null) {
await keep(before);
}
return before;
}
@override
Future<void> keep(String? token) async {
await _storage.delete(key: _key);
if (token != null) {
await _storage.write(key: _key, value: token);
}
await _channel.invokeMethod<void>('tokenKeep', {
'person': _person,
'token': token,
});
await _keyring.delete(key: _keyringKey);
}
@override
Future<void> forgetOthers() async {
for (final key in (await _storage.readAll()).keys) {
if (key.startsWith(_prefix) && key != _key) {
await _storage.delete(key: key);
await _channel.invokeMethod<void>('tokenForgetOthers', {'person': _person});
for (final key in [...(await _keyring.readAll()).keys]) {
if (key.startsWith(_keyringPrefix) && key != _keyringKey) {
await _keyring.delete(key: key);
}
}
}

View file

@ -0,0 +1,82 @@
import 'package:flutter/services.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/music_store.dart';
/// The Jellyfin token in Train's own store on Android (ADR 0030, D52), over
/// the music channel (`JellyfinTokens.kt`, a store in memory here); a token
/// the keyring kept before is moved over once and leaves the keyring.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const channel = MethodChannel('app.halkyon.train/music');
late Map<String, String> kept;
setUp(() {
kept = {};
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
final arguments = call.arguments as Map<Object?, Object?>;
final person = arguments['person']! as String;
switch (call.method) {
case 'tokenRead':
return kept[person];
case 'tokenKeep':
if (arguments['token'] case final String token) {
kept[person] = token;
} else {
kept.remove(person);
}
case 'tokenForgetOthers':
kept.removeWhere((key, _) => key != person);
}
return null;
});
});
tearDown(
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null),
);
test(
'a token kept is read back from the store, and null forgets it',
() async {
FlutterSecureStorage.setMockInitialValues({});
final tokens = AndroidJellyfinTokenStore('ada');
await tokens.keep('token-1');
expect(await tokens.read(), 'token-1');
expect(kept, {'ada': 'token-1'});
await tokens.keep(null);
expect(await tokens.read(), isNull);
},
);
test("the keyring's token is moved over once and leaves the keyring; "
"others' are forgotten in both", () async {
FlutterSecureStorage.setMockInitialValues({
'train_jellyfin:ada': 'before',
'train_jellyfin:bob': 'his',
'halkyon_account_key': 'not music',
});
kept['bob'] = 'his';
final tokens = AndroidJellyfinTokenStore('ada');
await tokens.forgetOthers();
expect(await tokens.read(), 'before');
const keyring = FlutterSecureStorage();
expect(await keyring.readAll(), {'halkyon_account_key': 'not music'});
expect(kept, {'ada': 'before'});
});
test('a token kept anew leaves no old one in the keyring', () async {
FlutterSecureStorage.setMockInitialValues({'train_jellyfin:ada': 'before'});
final tokens = AndroidJellyfinTokenStore('ada');
await tokens.keep('after');
expect(await tokens.read(), 'after');
expect(await const FlutterSecureStorage().readAll(), isEmpty);
});
}