diff --git a/lib/src/core/engine/iap_service.dart b/lib/src/core/engine/iap_service.dart index 19bfbea..a51b66b 100644 --- a/lib/src/core/engine/iap_service.dart +++ b/lib/src/core/engine/iap_service.dart @@ -1,9 +1,16 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; +import 'dart:typed_data'; import 'package:flutter/foundation.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:in_app_purchase/in_app_purchase.dart'; -import 'package:shared_preferences/shared_preferences.dart'; +import 'package:pointycastle/export.dart' as pc; +import 'package:pointycastle/asn1/asn1_parser.dart'; +import 'package:pointycastle/asn1/primitives/asn1_bit_string.dart'; +import 'package:pointycastle/asn1/primitives/asn1_integer.dart'; +import 'package:pointycastle/asn1/primitives/asn1_sequence.dart'; import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n; @@ -42,10 +49,19 @@ enum IAPResult { debugSimulated, } +/// Google Play 공개키 (public key) +/// +/// Google Play Console > 수익 창출(Monetization) 설정 > 라이선스(Licensing)에서 +/// Base64 인코딩된 RSA 공개키를 복사하여 아래에 붙여넣기. +/// 공개키이므로 비밀이 아니지만, 바이너리 스캔 방지를 위해 분할 저장. +const _gpKeyPart1 = 'YOUR_GOOGLE_PLAY_'; +const _gpKeyPart2 = 'PUBLIC_KEY_HERE'; +String get _googlePlayPublicKey => _gpKeyPart1 + _gpKeyPart2; + /// IAP 서비스 /// /// 인앱 구매 (광고 제거) 처리를 담당합니다. -/// shared_preferences를 사용하여 구매 상태를 영구 저장합니다. +/// flutter_secure_storage를 사용하여 구매 상태를 보안 저장합니다. class IAPService { IAPService._(); @@ -64,6 +80,9 @@ class IAPService { /// 구매 상태 저장 키 static const String _purchaseKey = 'iap_remove_ads_purchased'; + /// 보안 저장소 (secure storage) 인스턴스 + static const FlutterSecureStorage _secureStorage = FlutterSecureStorage(); + // =========================================================================== // 상태 // =========================================================================== @@ -160,17 +179,19 @@ class IAPService { }); } - /// 저장된 구매 상태 로드 + /// 저장된 구매 상태 로드 (보안 저장소 사용) Future _loadPurchaseState() async { - final prefs = await SharedPreferences.getInstance(); - _adRemovalPurchased = prefs.getBool(_purchaseKey) ?? false; + final value = await _secureStorage.read(key: _purchaseKey); + _adRemovalPurchased = value == 'true'; debugPrint('[IAPService] Loaded purchase state: $_adRemovalPurchased'); } - /// 구매 상태 저장 + /// 구매 상태 저장 (보안 저장소 사용) Future _savePurchaseState(bool purchased) async { - final prefs = await SharedPreferences.getInstance(); - await prefs.setBool(_purchaseKey, purchased); + await _secureStorage.write( + key: _purchaseKey, + value: purchased.toString(), + ); _adRemovalPurchased = purchased; debugPrint('[IAPService] Saved purchase state: $purchased'); } @@ -328,18 +349,133 @@ class IAPService { } } - /// 구매 성공 처리 + /// 구매 성공 처리 (서명 검증 포함) Future _handleSuccessfulPurchase(PurchaseDetails purchase) async { if (purchase.productID == IAPProductIds.removeAds) { - // 구매 검증 (서버 검증 생략, 로컬 저장) + // 플랫폼별 로컬 영수증 검증 (local receipt verification) + final verified = _verifyPurchaseLocally(purchase); + if (!verified) { + debugPrint('[IAPService] Purchase verification FAILED - not granting'); + await _completePurchase(purchase); + return; + } + await _savePurchaseState(true); - debugPrint('[IAPService] Ad removal purchased successfully'); + debugPrint('[IAPService] Ad removal purchased & verified successfully'); } // 구매 완료 처리 await _completePurchase(purchase); } + // =========================================================================== + // 로컬 영수증 검증 (local receipt verification) + // =========================================================================== + + /// 플랫폼별 로컬 구매 검증 + /// + /// Google Play: RSASSA-PKCS1-v1_5 + SHA1 서명 검증 + /// Apple: StoreKit 2 플러그인 자체 검증 (향후 JWS 로컬 검증 추가 예정) + bool _verifyPurchaseLocally(PurchaseDetails purchase) { + final source = purchase.verificationData.source; + + if (source == 'google_play') { + return _verifyGooglePlayPurchase(purchase); + } else if (source == 'app_store') { + // TODO: Apple StoreKit 2 JWS 로컬 검증 추가 + // 현재는 in_app_purchase 플러그인의 네이티브 검증에 의존 + debugPrint('[IAPService] Apple purchase - plugin-native verification'); + return true; + } + + debugPrint('[IAPService] Unknown source: $source - skipping verification'); + return true; + } + + /// Google Play 구매 RSA 서명 검증 + /// + /// localVerificationData는 Base64 인코딩된 서명 JSON 데이터. + /// serverVerificationData에 원본 구매 JSON이 포함됨. + bool _verifyGooglePlayPurchase(PurchaseDetails purchase) { + try { + final signedData = purchase.verificationData.serverVerificationData; + final signature = purchase.verificationData.localVerificationData; + + if (signedData.isEmpty || signature.isEmpty) { + debugPrint('[IAPService] Empty verification data'); + return false; + } + + return _verifyRsaSignature(signedData, signature, _googlePlayPublicKey); + } catch (e) { + debugPrint('[IAPService] Google Play verification error: $e'); + return false; + } + } + + /// RSASSA-PKCS1-v1_5 + SHA1 서명 검증 + /// + /// Google Play 서명 검증에 사용되는 RSA 알고리즘. + /// [data]: 서명된 원본 데이터 (signed data) + /// [signatureBase64]: Base64 인코딩된 서명 (signature) + /// [publicKeyBase64]: Base64 인코딩된 DER 공개키 (public key) + bool _verifyRsaSignature( + String data, + String signatureBase64, + String publicKeyBase64, + ) { + try { + // 공개키 DER 바이트 디코딩 (decode public key) + final publicKeyBytes = base64Decode(publicKeyBase64); + final publicKey = _parsePublicKeyFromDer(publicKeyBytes); + + // 서명 디코딩 (decode signature) + final signatureBytes = base64Decode(signatureBase64); + + // 원본 데이터를 UTF-8 바이트로 변환 + final dataBytes = Uint8List.fromList(utf8.encode(data)); + + // RSASSA-PKCS1-v1_5 + SHA1 검증 + final signer = pc.Signer('SHA-1/RSA'); + signer.init( + false, // verify 모드 + pc.PublicKeyParameter(publicKey), + ); + + return signer.verifySignature( + dataBytes, + pc.RSASignature(signatureBytes), + ); + } catch (e) { + debugPrint('[IAPService] RSA verification error: $e'); + return false; + } + } + + /// DER 인코딩된 공개키 파싱 (parse DER-encoded public key) + /// + /// X.509 SubjectPublicKeyInfo 형식의 DER 바이트에서 RSA 공개키 추출. + pc.RSAPublicKey _parsePublicKeyFromDer(Uint8List bytes) { + // ASN.1 시퀀스 파서 (ASN.1 sequence parser) + final asn1Parser = ASN1Parser(bytes); + final topSequence = asn1Parser.nextObject() as ASN1Sequence; + + // SubjectPublicKeyInfo 내부의 BitString에서 공개키 추출 + final bitString = topSequence.elements![1] as ASN1BitString; + final publicKeyBytes = bitString.stringValues as Uint8List; + + // RSA 공개키 시퀀스 파싱 + final rsaParser = ASN1Parser(publicKeyBytes); + final rsaSequence = rsaParser.nextObject() as ASN1Sequence; + + final modulus = + (rsaSequence.elements![0] as ASN1Integer).integer!; + final exponent = + (rsaSequence.elements![1] as ASN1Integer).integer!; + + return pc.RSAPublicKey(modulus, exponent); + } + /// 구매 완료 처리 (필수) Future _completePurchase(PurchaseDetails purchase) async { if (purchase.pendingCompletePurchase) { diff --git a/lib/src/core/storage/save_integrity.dart b/lib/src/core/storage/save_integrity.dart new file mode 100644 index 0000000..3fa55bb --- /dev/null +++ b/lib/src/core/storage/save_integrity.dart @@ -0,0 +1,129 @@ +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; + +/// 세이브 파일 HMAC-SHA256 무결성(integrity) 검증 유틸리티. +/// +/// 파일 포맷: [32-byte HMAC][GZip data] +/// 구 포맷(legacy): [GZip data] (HMAC 없음, GZip 매직 바이트 0x1f 0x8b로 시작) +class SaveIntegrity { + SaveIntegrity._(); + + /// HMAC-SHA256 출력 길이 (bytes) + static const int hmacLength = 32; + + /// GZip 매직 바이트 (magic bytes) — 구 포맷 판별용 + static const int _gzipMagic1 = 0x1f; + static const int _gzipMagic2 = 0x8b; + + /// 난독화(obfuscation)된 HMAC 키 생성. + /// 소스에 평문(plaintext)으로 저장하지 않기 위해 XOR 분할. + static List get _hmacKey { + // 파트 A: 원본 키의 전반부 + const partA = [ + 0x41, 0x73, 0x63, 0x69, 0x69, 0x4e, 0x65, 0x76, + 0x65, 0x72, 0x44, 0x69, 0x65, 0x53, 0x61, 0x76, + ]; + // 파트 B: XOR 마스크(mask) + const mask = [ + 0x7a, 0x1c, 0x0f, 0x05, 0x0d, 0x22, 0x09, 0x1a, + 0x09, 0x1e, 0x28, 0x05, 0x09, 0x3f, 0x0d, 0x1a, + ]; + // 파트 C: partA XOR mask 결과 (키 후반부) + const partC = [ + 0x3b, 0x6f, 0x6c, 0x6c, 0x64, 0x6c, 0x6c, 0x6c, + 0x6c, 0x6c, 0x6c, 0x6c, 0x6c, 0x6c, 0x6c, 0x6c, + ]; + + // 전반부(partA) + 후반부(partC XOR mask)로 32바이트 키 생성 + final key = List.filled(32, 0); + for (var i = 0; i < 16; i++) { + key[i] = partA[i]; + key[i + 16] = partC[i] ^ mask[i]; + } + return key; + } + + /// GZip 데이터에 HMAC-SHA256 서명(signature) 추가. + /// 반환: [32-byte HMAC][gzipBytes] + static Uint8List sign(List gzipBytes) { + final mac = _computeHmac(gzipBytes); + final result = Uint8List(hmacLength + gzipBytes.length); + result.setRange(0, hmacLength, mac.bytes); + result.setRange(hmacLength, result.length, gzipBytes); + return result; + } + + /// 파일 바이트에서 HMAC를 검증(verify)하고 GZip 데이터를 반환. + /// + /// - HMAC 검증 성공: GZip 바이트 반환 + /// - 구 포맷(legacy, HMAC 없음): GZip 바이트 그대로 반환 + [isLegacy] = true + /// - HMAC 검증 실패: [SaveIntegrityException] 발생 + static SaveIntegrityResult verify(List fileBytes) { + if (_isLegacyFormat(fileBytes)) { + return SaveIntegrityResult( + gzipBytes: Uint8List.fromList(fileBytes), + isLegacy: true, + ); + } + + if (fileBytes.length < hmacLength) { + throw const SaveIntegrityException('파일이 너무 작습니다'); + } + + final storedHmac = fileBytes.sublist(0, hmacLength); + final gzipBytes = fileBytes.sublist(hmacLength); + final computed = _computeHmac(gzipBytes); + + // 상수 시간(constant-time) 비교로 타이밍 공격(timing attack) 방지 + var match = true; + for (var i = 0; i < hmacLength; i++) { + if (storedHmac[i] != computed.bytes[i]) { + match = false; + } + } + + if (!match) { + throw const SaveIntegrityException('세이브 파일 무결성 검증 실패'); + } + + return SaveIntegrityResult( + gzipBytes: Uint8List.fromList(gzipBytes), + isLegacy: false, + ); + } + + /// 구 포맷 판별: GZip 매직 바이트(0x1f 0x8b)로 시작하면 HMAC 없는 레거시 + static bool _isLegacyFormat(List bytes) { + if (bytes.length < 2) return false; + return bytes[0] == _gzipMagic1 && bytes[1] == _gzipMagic2; + } + + static Digest _computeHmac(List data) { + final hmac = Hmac(sha256, _hmacKey); + return hmac.convert(data); + } +} + +/// HMAC 검증 결과(result) +class SaveIntegrityResult { + const SaveIntegrityResult({ + required this.gzipBytes, + required this.isLegacy, + }); + + /// HMAC을 제외한 순수 GZip 데이터 + final Uint8List gzipBytes; + + /// 구 포맷(legacy) 여부 — true면 HMAC 없이 로드됨 + final bool isLegacy; +} + +/// 세이브 파일 무결성 검증 실패 예외(exception) +class SaveIntegrityException implements Exception { + const SaveIntegrityException(this.message); + final String message; + + @override + String toString() => 'SaveIntegrityException: $message'; +} diff --git a/lib/src/core/storage/save_repository.dart b/lib/src/core/storage/save_repository.dart index 77407d8..b1f68e0 100644 --- a/lib/src/core/storage/save_repository.dart +++ b/lib/src/core/storage/save_repository.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:asciineverdie/src/core/model/save_data.dart'; +import 'package:asciineverdie/src/core/storage/save_integrity.dart'; import 'package:asciineverdie/src/core/storage/save_service.dart'; import 'package:path_provider/path_provider.dart'; @@ -45,6 +46,8 @@ class SaveRepository { } on FileSystemException catch (e) { final reason = e.osError?.message ?? e.message; return (SaveOutcome.failure('Unable to load save: $reason'), null); + } on SaveIntegrityException catch (e) { + return (SaveOutcome.failure('Tampered save file: ${e.message}'), null); } on FormatException catch (e) { return (SaveOutcome.failure('Corrupted save file: ${e.message}'), null); } catch (e) { diff --git a/lib/src/core/storage/save_service.dart b/lib/src/core/storage/save_service.dart index 4e6f23e..925d6bc 100644 --- a/lib/src/core/storage/save_service.dart +++ b/lib/src/core/storage/save_service.dart @@ -1,9 +1,13 @@ import 'dart:convert'; +import 'dart:developer' as developer; import 'dart:io'; import 'package:asciineverdie/src/core/model/save_data.dart'; +import 'package:asciineverdie/src/core/storage/save_integrity.dart'; -/// Persists GameSave as JSON compressed with GZipCodec. +/// Persists GameSave as JSON compressed with GZipCodec + HMAC-SHA256 integrity. +/// +/// 파일 포맷: [32-byte HMAC][GZip data] class SaveService { SaveService({required this.baseDir}); @@ -17,14 +21,26 @@ class SaveService { final jsonStr = jsonEncode(save.toJson()); final bytes = utf8.encode(jsonStr); final compressed = _gzip.encode(bytes); - return file.writeAsBytes(compressed); + // HMAC-SHA256 서명(signature) 추가 + final signed = SaveIntegrity.sign(compressed); + return file.writeAsBytes(signed); } Future load(String fileName) async { final path = _resolvePath(fileName); final file = File(path); - final compressed = await file.readAsBytes(); - final decompressed = _gzip.decode(compressed); + final fileBytes = await file.readAsBytes(); + + // HMAC 무결성(integrity) 검증 — 구 포맷은 경고 후 통과 + final result = SaveIntegrity.verify(fileBytes); + if (result.isLegacy) { + developer.log( + '레거시(legacy) 세이브 포맷 감지: $fileName — 다음 저장 시 HMAC 자동 추가', + name: 'SaveService', + ); + } + + final decompressed = _gzip.decode(result.gzipBytes); final jsonStr = utf8.decode(decompressed); final map = jsonDecode(jsonStr) as Map; return GameSave.fromJson(map); diff --git a/pubspec.lock b/pubspec.lock index c41b7b1..1cbcc7a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -178,21 +178,13 @@ packages: source: hosted version: "3.1.2" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf url: "https://pub.dev" source: hosted version: "3.0.7" - cupertino_icons: - dependency: "direct main" - description: - name: cupertino_icons - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 - url: "https://pub.dev" - source: hosted - version: "1.0.8" dart_style: dependency: transitive description: @@ -259,6 +251,54 @@ packages: description: flutter source: sdk version: "0.0.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_test: dependency: "direct dev" description: flutter @@ -401,10 +441,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: "direct main" description: @@ -621,6 +661,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pointycastle: + dependency: "direct main" + description: + name: pointycastle + sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" + url: "https://pub.dev" + source: hosted + version: "3.9.1" pool: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 7712265..7645489 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -33,7 +33,6 @@ dependencies: flutter_localizations: sdk: flutter - cupertino_icons: ^1.0.8 intl: ^0.20.2 path_provider: ^2.1.4 shared_preferences: ^2.3.1 @@ -45,6 +44,12 @@ dependencies: google_mobile_ads: ^5.3.0 # IAP (인앱 결제) in_app_purchase: ^3.2.0 + # IAP 구매 상태 보안 저장 (secure storage) + flutter_secure_storage: ^9.2.4 + # 세이브 파일 무결성(integrity) 검증용 HMAC-SHA256 + crypto: ^3.0.6 + # Google Play 영수증 RSA 서명 검증 (signature verification) + pointycastle: ^3.9.1 # 앱 버전 정보 package_info_plus: ^8.3.0