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
8 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);
});
}

View file

@ -1310,6 +1310,44 @@ Strava. What was found, 27 Oct:
ride starts with), and GPS's speed is ignored while the wheel sent within 5 s; a
ride without a circumference (no speed sensor) pauses by GPS as before. Not tried
with a real sensor or Health Connect.
- **D52 Music kept in the background, and smaller copies** (recommended, the
developer's; 29 Oct; step 14, what D47's 10b left open):
- **Downloads by a WorkManager job of Train's, in Kotlin, on the app's list** (14b;
over the job also reading the marked playlists, which writes part of the Jellyfin
client a second time; over the workmanager plugin running today's Dart in a
background engine, a new plugin and a whole engine for each run; over Android's
DownloadManager, which keeps the token in the system's download database): the
app, as it opens and resumes, reads the playlists as before, deletes what they no
longer hold and writes which tracks are missing; the job downloads those and works
out their tempo, a line each in the person's music folder, which the app takes in
as it opens and, while open, as each line comes. A playlist changed on the server
while the app is closed is noticed as it opens. The job does every download, the
open app's too, so that one code path runs and two never fetch the same track.
- **The token in one store of Train's, in Kotlin** (14a; over a second copy beside
flutter_secure_storage's, two to keep in step, and over the job reading that
plugin's own files, whose format has changed between versions): a file per person
in the no-backup folder, sealed with AES-GCM under a key in Android's Keystore
that can't be exported; the app reads it over the music channel; a token kept
before is moved over once and deleted from the keyring.
- **When**: every 6 hours and once whenever the list changes, on an unmetered network,
with the battery and the storage not low (over daily while charging, which leaves
a new playlist waiting overnight, and over every 12 hours).
- **Smaller copies** (14c): the music screen chooses *As they are* or *Smaller*, the
default *Smaller*: files above 256 kbps (FLAC and other lossless) are transcoded by
Jellyfin to Opus at 128 kbps in WebM, which Android's extractor and decoder read
on every version Train runs on; MP3s and AACs come as they are (over *As they are*
by default, and over always transcoding, which loses an MP3's quality twice and
has the server's processor do every track). Switching downloads the tracks again;
a file is kept until its new copy is whole, its tempo kept.
- Built: 14a. `JellyfinTokens.kt` keeps each person's token in the no-backup
folder's `jellyfin/`, a file named by their ID: a version byte, the IV the
Keystore makes and the token sealed with AES-GCM under `train_jellyfin`, a
256-bit key of Android's Keystore made once, the person's ID its associated data;
a file that doesn't open is deleted and counts as none, so the person signs in
again. Tested on the JVM with a software key. `AndroidJellyfinTokenStore` asks it
over the music channel and moves a token the keyring holds under
`train_jellyfin:` over as it is first read; keeping one, and forgetting others',
clears the keyring's too.
## Consequences