From ab18b544c200e7463e9b55a21e14fbea92d79a36 Mon Sep 17 00:00:00 2001 From: Ponshu Developer Date: Thu, 23 Apr 2026 10:23:18 +0900 Subject: [PATCH] =?UTF-8?q?security:=20=E3=83=A9=E3=82=A4=E3=82=BB?= =?UTF-8?q?=E3=83=B3=E3=82=B9=E3=82=AD=E3=83=BC=E3=82=92=20flutter=5Fsecur?= =?UTF-8?q?e=5Fstorage=20=E3=81=B8=E7=A7=BB=E8=A1=8C=E3=80=81Pro=20UI=20?= =?UTF-8?q?=E3=81=A1=E3=82=89=E3=81=A4=E3=81=8D=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SharedPreferences のライセンスキーを FlutterSecureStorage(Android 暗号化)に移行 - 既存ユーザー向け一回限りのマイグレーション処理を追加(ponshu_license_migrated_v1 フラグ) - LicenseService.getCachedStatusOnly() を追加(ネットワーク不要の即時キャッシュ返却) - licenseStatusProvider を FutureProvider から AsyncNotifier に変換 - main() でキャッシュを事前ロードし licenseInitialStatusProvider に渡すことで 起動時の loading → false → pro のちらつきを根本解消 - バックグラウンドでサーバー検証を実行し、差異があれば状態を更新 Co-Authored-By: Claude Sonnet 4.6 --- lib/main.dart | 13 ++- lib/providers/license_provider.dart | 49 +++++++++-- lib/services/license_service.dart | 86 ++++++++++++++----- pubspec.lock | 52 ++++++++++- pubspec.yaml | 3 +- .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 7 files changed, 174 insertions(+), 33 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 78f7dd6..9ed86dc 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,6 +9,8 @@ import 'providers/theme_provider.dart'; import 'screens/main_screen.dart'; import 'screens/license_screen.dart'; import 'services/migration_service.dart'; +import 'services/license_service.dart'; +import 'providers/license_provider.dart'; /// 店舗向けビルドかどうかを判定するビルド時フラグ /// @@ -74,9 +76,16 @@ void main() async { } } + // ちらつき防止: runApp の前にキャッシュ済みライセンス状態を取得し、 + // licenseStatusProvider が loading を経由せず即 AsyncData になるよう override する + final cachedLicenseStatus = await LicenseService.getCachedStatusOnly(); + runApp( - const ProviderScope( - child: MyApp(), + ProviderScope( + overrides: [ + licenseInitialStatusProvider.overrideWithValue(cachedLicenseStatus), + ], + child: const MyApp(), ), ); } diff --git a/lib/providers/license_provider.dart b/lib/providers/license_provider.dart index 7a03398..b615153 100644 --- a/lib/providers/license_provider.dart +++ b/lib/providers/license_provider.dart @@ -1,15 +1,52 @@ +import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../services/license_service.dart'; -/// ライセンス状態の非同期プロバイダー +/// 起動時にキャッシュから事前ロードした値を保持するプロバイダー /// -/// アプリ起動時に一度だけVPSに問い合わせ、結果をキャッシュする。 -/// 手動更新は [licenseStatusProvider].invalidate() を呼ぶ。 -final licenseStatusProvider = FutureProvider((ref) async { - return LicenseService.checkStatus(); -}); +/// main() で override して初期値を渡すことで、 +/// licenseStatusProvider が loading 状態を経由せず即座に AsyncData になる。 +final licenseInitialStatusProvider = Provider((ref) => null); + +/// ライセンス状態プロバイダー +/// +/// - build() は licenseInitialStatusProvider に値がある場合は同期で返す(ちらつきなし) +/// - 同時にバックグラウンドでサーバー検証を実行し、差異があれば状態を更新する +final licenseStatusProvider = + AsyncNotifierProvider( + LicenseStatusNotifier.new, +); + +class LicenseStatusNotifier extends AsyncNotifier { + @override + FutureOr build() { + final initial = ref.read(licenseInitialStatusProvider); + if (initial != null) { + // キャッシュ値を同期で返すことで loading 状態をスキップ + _refreshFromServer(); + return initial; + } + // override なし(テスト等): 通常フロー + return LicenseService.checkStatus(); + } + + /// バックグラウンドでサーバー検証を実行し、変化があれば状態を更新する + Future _refreshFromServer() async { + try { + final fresh = await LicenseService.checkStatus(); + if (state.hasValue && state.value != fresh) { + state = AsyncData(fresh); + } + } catch (_) { + // ネットワークエラーはキャッシュ値を維持 + } + } +} /// Pro版かどうか(ナビゲーション・機能解放の分岐に使う) +/// +/// licenseStatusProvider が同期で AsyncData を返すため、 +/// アプリ起動時に false をちらつかせることなく正しい値を返す。 final isProProvider = Provider((ref) { final statusAsync = ref.watch(licenseStatusProvider); return statusAsync.maybeWhen( diff --git a/lib/services/license_service.dart b/lib/services/license_service.dart index 63ce4d9..7a6202f 100644 --- a/lib/services/license_service.dart +++ b/lib/services/license_service.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; import 'device_service.dart'; @@ -22,43 +23,81 @@ enum LicenseStatus { /// ライセンス管理サービス /// +/// ## ストレージ方針 +/// - ライセンスキー本体: flutter_secure_storage(暗号化) +/// - 状態キャッシュ: SharedPreferences(平文でもリスクなし) +/// /// ## 状態管理の優先順位 -/// 1. オンライン時: VPSに問い合わせた結果を使用 + SharedPreferencesにキャッシュ -/// 2. オフライン時: SharedPreferencesのキャッシュを使用 (Pro状態を維持) +/// 1. 起動時: SecureStorage のキャッシュを即時返却(ちらつき防止) +/// 2. バックグラウンド: VPS で再検証し、差異があれば状態を更新 +/// 3. オフライン時: SharedPreferences のキャッシュを維持 /// /// ## ライセンスキー形式 /// PONSHU-XXXX-XXXX-XXXX (6バイト = 12hex文字, 大文字) class LicenseService { - static const _prefLicenseKey = 'ponshu_license_key'; - static const _prefCachedStatus = 'ponshu_license_status_cache'; - static const _prefCachedAt = 'ponshu_license_cached_at'; - static const _cacheValidSeconds = 24 * 60 * 60; // 24時間キャッシュ有効 + static const _secureKeyName = 'ponshu_license_key'; + static const _prefCachedStatus = 'ponshu_license_status_cache'; + static const _prefCachedAt = 'ponshu_license_cached_at'; + static const _prefMigratedV1 = 'ponshu_license_migrated_v1'; + static const _cacheValidSeconds = 24 * 60 * 60; // 24時間 + + static const _storage = FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + ); + + // ========== Migration ========== + + /// SharedPreferences → flutter_secure_storage の一回限りのマイグレーション + static Future _migrateIfNeeded() async { + final prefs = await SharedPreferences.getInstance(); + if (prefs.getBool(_prefMigratedV1) == true) return; + + // 旧ストレージにキーがあれば移行して削除 + final oldValue = prefs.getString(_secureKeyName); + if (oldValue != null && oldValue.isNotEmpty) { + await _storage.write(key: _secureKeyName, value: oldValue); + await prefs.remove(_secureKeyName); + debugPrint('[License] Migrated license key to secure storage.'); + } + await prefs.setBool(_prefMigratedV1, true); + } // ========== Public API ========== - /// アプリ起動時に呼ぶ: ライセンス状態を確認して返す - static Future checkStatus() async { - final prefs = await SharedPreferences.getInstance(); - final savedKey = prefs.getString(_prefLicenseKey) ?? ''; + /// キャッシュのみを即時返却(サーバー問い合わせなし) + /// + /// 起動時のちらつき防止用。main() で await してから runApp() に渡す。 + static Future getCachedStatusOnly() async { + await _migrateIfNeeded(); + final savedKey = await _storage.read(key: _secureKeyName) ?? ''; + if (savedKey.isEmpty) return LicenseStatus.free; + final prefs = await SharedPreferences.getInstance(); + return _getCachedStatus(prefs); + } + + /// アプリ起動時(バックグラウンド): VPS でライセンス状態を検証して返す + static Future checkStatus() async { + await _migrateIfNeeded(); + final savedKey = await _storage.read(key: _secureKeyName) ?? ''; - // ライセンスキーが保存済み → サーバーで検証 if (savedKey.isNotEmpty) { try { final status = await _validateKeyWithServer(savedKey); if (status == LicenseStatus.offline) { - // ネットワーク不通: キャッシュを上書きせずに返す debugPrint('[License] Server unreachable, using cache'); + final prefs = await SharedPreferences.getInstance(); return _getCachedStatus(prefs); } + final prefs = await SharedPreferences.getInstance(); await _cacheStatus(prefs, status); return status; } catch (e) { debugPrint('[License] Server unreachable, using cache: $e'); + final prefs = await SharedPreferences.getInstance(); return _getCachedStatus(prefs); } } - // ライセンスキーなし → 無料版 return LicenseStatus.free; } @@ -76,8 +115,8 @@ class LicenseService { final status = await _validateKeyWithServer(key); if (status == LicenseStatus.pro) { + await _storage.write(key: _secureKeyName, value: key); final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_prefLicenseKey, key); await _cacheStatus(prefs, LicenseStatus.pro); debugPrint('[License] Activated successfully.'); return (success: true, message: ''); @@ -96,14 +135,14 @@ class LicenseService { /// ライセンスキーがローカルに保存されているか static Future hasLicenseKey() async { - final prefs = await SharedPreferences.getInstance(); - return (prefs.getString(_prefLicenseKey) ?? '').isNotEmpty; + await _migrateIfNeeded(); + return ((await _storage.read(key: _secureKeyName)) ?? '').isNotEmpty; } /// ライセンスをリセット(デバッグ用) static Future reset() async { + await _storage.delete(key: _secureKeyName); final prefs = await SharedPreferences.getInstance(); - await prefs.remove(_prefLicenseKey); await prefs.remove(_prefCachedStatus); await prefs.remove(_prefCachedAt); debugPrint('[License] Reset complete.'); @@ -128,7 +167,11 @@ class LicenseService { final data = jsonDecode(utf8.decode(response.bodyBytes)) as Map; if (data['valid'] == true) return LicenseStatus.pro; - if ((data['error'] as String? ?? '').contains('無効化')) return LicenseStatus.revoked; + // revoked フィールド(boolean)を優先し、error メッセージ文字列にも対応 + if (data['revoked'] == true || + (data['error'] as String? ?? '').contains('無効化')) { + return LicenseStatus.revoked; + } return LicenseStatus.free; } catch (e) { @@ -143,19 +186,18 @@ class LicenseService { } static LicenseStatus _getCachedStatus(SharedPreferences prefs) { - final cached = prefs.getString(_prefCachedStatus); + final cached = prefs.getString(_prefCachedStatus); final cachedAt = prefs.getString(_prefCachedAt); if (cached == null) return LicenseStatus.free; - // オンライン時は _validateKeyWithServer が常に上書きするため、 + // オンライン時は checkStatus が常に上書きするため、 // _getCachedStatus はオフライン時専用のフォールバックとして動作する。 // // TTL 判定(_cacheValidSeconds = 24h): - // - free / offline は期限切れで free にフォールバック + // - free / offline: 期限切れで free にフォールバック // - pro : 購入者をオフライン時に締め出さないため永続扱い // - revoked: 不正防止を優先するため永続扱い - // (将来 TTL を設けたい場合は isNoExpiryStatus を条件分岐ごと差し替える) if (cachedAt != null) { final age = DateTime.now().difference(DateTime.parse(cachedAt)); final isNoExpiryStatus = cached == LicenseStatus.pro.name || cached == LicenseStatus.revoked.name; diff --git a/pubspec.lock b/pubspec.lock index a0d4322..3f0e08c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -515,6 +515,54 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" flutter_speed_dial: dependency: "direct main" description: @@ -801,10 +849,10 @@ packages: dependency: transitive description: name: js - sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 url: "https://pub.dev" source: hosted - version: "0.7.2" + version: "0.6.7" json_annotation: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index daabc62..e4362ff 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -46,7 +46,7 @@ dependencies: device_info_plus: ^10.1.0 http: ^1.2.0 crypto: ^3.0.3 # SHA-256 ハッシュ計算用(キャッシュ) - lucide_icons: ^0.257.0 + lucide_icons: 0.257.0 reorderable_grid_view: ^2.2.5 camera: ^0.11.3 path_provider: ^2.1.5 @@ -61,6 +61,7 @@ dependencies: package_info_plus: ^8.1.2 gal: ^2.3.0 shared_preferences: ^2.5.4 + flutter_secure_storage: ^9.2.2 # Phase 9: Google Drive Backup googleapis: ^13.2.0 diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 616f14a..e06ef80 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -18,6 +19,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); + FlutterSecureStorageWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); GalPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("GalPluginCApi")); PrintingPluginRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 57ebdd9..9c16431 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST connectivity_plus file_selector_windows + flutter_secure_storage_windows gal printing share_plus