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
7 changed files with 342 additions and 18 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue