feat(security): Phase 1 보안 강화 — IAP RSA 검증, HMAC 체크섬, Secure Storage
- iap_service: Google Play RSA 서명 검증 (pointycastle) - iap_service: SharedPreferences → flutter_secure_storage 전환 - save_integrity: 세이브 파일 HMAC-SHA256 무결성 검증 추가 - save_service: HMAC sign/verify 적용 (레거시 포맷 호환) - pubspec: pointycastle, crypto, flutter_secure_storage 의존성 추가 - pubspec: 미사용 cupertino_icons 제거
This commit is contained in:
@@ -1,9 +1,16 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
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: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;
|
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
|
||||||
|
|
||||||
@@ -42,10 +49,19 @@ enum IAPResult {
|
|||||||
debugSimulated,
|
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 서비스
|
/// IAP 서비스
|
||||||
///
|
///
|
||||||
/// 인앱 구매 (광고 제거) 처리를 담당합니다.
|
/// 인앱 구매 (광고 제거) 처리를 담당합니다.
|
||||||
/// shared_preferences를 사용하여 구매 상태를 영구 저장합니다.
|
/// flutter_secure_storage를 사용하여 구매 상태를 보안 저장합니다.
|
||||||
class IAPService {
|
class IAPService {
|
||||||
IAPService._();
|
IAPService._();
|
||||||
|
|
||||||
@@ -64,6 +80,9 @@ class IAPService {
|
|||||||
/// 구매 상태 저장 키
|
/// 구매 상태 저장 키
|
||||||
static const String _purchaseKey = 'iap_remove_ads_purchased';
|
static const String _purchaseKey = 'iap_remove_ads_purchased';
|
||||||
|
|
||||||
|
/// 보안 저장소 (secure storage) 인스턴스
|
||||||
|
static const FlutterSecureStorage _secureStorage = FlutterSecureStorage();
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
// 상태
|
// 상태
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
@@ -160,17 +179,19 @@ class IAPService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 저장된 구매 상태 로드
|
/// 저장된 구매 상태 로드 (보안 저장소 사용)
|
||||||
Future<void> _loadPurchaseState() async {
|
Future<void> _loadPurchaseState() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final value = await _secureStorage.read(key: _purchaseKey);
|
||||||
_adRemovalPurchased = prefs.getBool(_purchaseKey) ?? false;
|
_adRemovalPurchased = value == 'true';
|
||||||
debugPrint('[IAPService] Loaded purchase state: $_adRemovalPurchased');
|
debugPrint('[IAPService] Loaded purchase state: $_adRemovalPurchased');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 구매 상태 저장
|
/// 구매 상태 저장 (보안 저장소 사용)
|
||||||
Future<void> _savePurchaseState(bool purchased) async {
|
Future<void> _savePurchaseState(bool purchased) async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
await _secureStorage.write(
|
||||||
await prefs.setBool(_purchaseKey, purchased);
|
key: _purchaseKey,
|
||||||
|
value: purchased.toString(),
|
||||||
|
);
|
||||||
_adRemovalPurchased = purchased;
|
_adRemovalPurchased = purchased;
|
||||||
debugPrint('[IAPService] Saved purchase state: $purchased');
|
debugPrint('[IAPService] Saved purchase state: $purchased');
|
||||||
}
|
}
|
||||||
@@ -328,18 +349,133 @@ class IAPService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 구매 성공 처리
|
/// 구매 성공 처리 (서명 검증 포함)
|
||||||
Future<void> _handleSuccessfulPurchase(PurchaseDetails purchase) async {
|
Future<void> _handleSuccessfulPurchase(PurchaseDetails purchase) async {
|
||||||
if (purchase.productID == IAPProductIds.removeAds) {
|
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);
|
await _savePurchaseState(true);
|
||||||
debugPrint('[IAPService] Ad removal purchased successfully');
|
debugPrint('[IAPService] Ad removal purchased & verified successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 구매 완료 처리
|
// 구매 완료 처리
|
||||||
await _completePurchase(purchase);
|
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<pc.RSAPublicKey>(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<void> _completePurchase(PurchaseDetails purchase) async {
|
Future<void> _completePurchase(PurchaseDetails purchase) async {
|
||||||
if (purchase.pendingCompletePurchase) {
|
if (purchase.pendingCompletePurchase) {
|
||||||
|
|||||||
129
lib/src/core/storage/save_integrity.dart
Normal file
129
lib/src/core/storage/save_integrity.dart
Normal file
@@ -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<int> get _hmacKey {
|
||||||
|
// 파트 A: 원본 키의 전반부
|
||||||
|
const partA = <int>[
|
||||||
|
0x41, 0x73, 0x63, 0x69, 0x69, 0x4e, 0x65, 0x76,
|
||||||
|
0x65, 0x72, 0x44, 0x69, 0x65, 0x53, 0x61, 0x76,
|
||||||
|
];
|
||||||
|
// 파트 B: XOR 마스크(mask)
|
||||||
|
const mask = <int>[
|
||||||
|
0x7a, 0x1c, 0x0f, 0x05, 0x0d, 0x22, 0x09, 0x1a,
|
||||||
|
0x09, 0x1e, 0x28, 0x05, 0x09, 0x3f, 0x0d, 0x1a,
|
||||||
|
];
|
||||||
|
// 파트 C: partA XOR mask 결과 (키 후반부)
|
||||||
|
const partC = <int>[
|
||||||
|
0x3b, 0x6f, 0x6c, 0x6c, 0x64, 0x6c, 0x6c, 0x6c,
|
||||||
|
0x6c, 0x6c, 0x6c, 0x6c, 0x6c, 0x6c, 0x6c, 0x6c,
|
||||||
|
];
|
||||||
|
|
||||||
|
// 전반부(partA) + 후반부(partC XOR mask)로 32바이트 키 생성
|
||||||
|
final key = List<int>.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<int> 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<int> 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<int> bytes) {
|
||||||
|
if (bytes.length < 2) return false;
|
||||||
|
return bytes[0] == _gzipMagic1 && bytes[1] == _gzipMagic2;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Digest _computeHmac(List<int> 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';
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:asciineverdie/src/core/model/save_data.dart';
|
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:asciineverdie/src/core/storage/save_service.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
@@ -45,6 +46,8 @@ class SaveRepository {
|
|||||||
} on FileSystemException catch (e) {
|
} on FileSystemException catch (e) {
|
||||||
final reason = e.osError?.message ?? e.message;
|
final reason = e.osError?.message ?? e.message;
|
||||||
return (SaveOutcome.failure('Unable to load save: $reason'), null);
|
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) {
|
} on FormatException catch (e) {
|
||||||
return (SaveOutcome.failure('Corrupted save file: ${e.message}'), null);
|
return (SaveOutcome.failure('Corrupted save file: ${e.message}'), null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:developer' as developer;
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:asciineverdie/src/core/model/save_data.dart';
|
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 {
|
class SaveService {
|
||||||
SaveService({required this.baseDir});
|
SaveService({required this.baseDir});
|
||||||
|
|
||||||
@@ -17,14 +21,26 @@ class SaveService {
|
|||||||
final jsonStr = jsonEncode(save.toJson());
|
final jsonStr = jsonEncode(save.toJson());
|
||||||
final bytes = utf8.encode(jsonStr);
|
final bytes = utf8.encode(jsonStr);
|
||||||
final compressed = _gzip.encode(bytes);
|
final compressed = _gzip.encode(bytes);
|
||||||
return file.writeAsBytes(compressed);
|
// HMAC-SHA256 서명(signature) 추가
|
||||||
|
final signed = SaveIntegrity.sign(compressed);
|
||||||
|
return file.writeAsBytes(signed);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<GameSave> load(String fileName) async {
|
Future<GameSave> load(String fileName) async {
|
||||||
final path = _resolvePath(fileName);
|
final path = _resolvePath(fileName);
|
||||||
final file = File(path);
|
final file = File(path);
|
||||||
final compressed = await file.readAsBytes();
|
final fileBytes = await file.readAsBytes();
|
||||||
final decompressed = _gzip.decode(compressed);
|
|
||||||
|
// 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 jsonStr = utf8.decode(decompressed);
|
||||||
final map = jsonDecode(jsonStr) as Map<String, dynamic>;
|
final map = jsonDecode(jsonStr) as Map<String, dynamic>;
|
||||||
return GameSave.fromJson(map);
|
return GameSave.fromJson(map);
|
||||||
|
|||||||
70
pubspec.lock
70
pubspec.lock
@@ -178,21 +178,13 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.2"
|
version: "3.1.2"
|
||||||
crypto:
|
crypto:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: crypto
|
name: crypto
|
||||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.7"
|
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:
|
dart_style:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -259,6 +251,54 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -401,10 +441,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: js
|
name: js
|
||||||
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
|
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.2"
|
version: "0.6.7"
|
||||||
json_annotation:
|
json_annotation:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -621,6 +661,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.8"
|
version: "2.1.8"
|
||||||
|
pointycastle:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: pointycastle
|
||||||
|
sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.9.1"
|
||||||
pool:
|
pool:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ dependencies:
|
|||||||
flutter_localizations:
|
flutter_localizations:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|
||||||
cupertino_icons: ^1.0.8
|
|
||||||
intl: ^0.20.2
|
intl: ^0.20.2
|
||||||
path_provider: ^2.1.4
|
path_provider: ^2.1.4
|
||||||
shared_preferences: ^2.3.1
|
shared_preferences: ^2.3.1
|
||||||
@@ -45,6 +44,12 @@ dependencies:
|
|||||||
google_mobile_ads: ^5.3.0
|
google_mobile_ads: ^5.3.0
|
||||||
# IAP (인앱 결제)
|
# IAP (인앱 결제)
|
||||||
in_app_purchase: ^3.2.0
|
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
|
package_info_plus: ^8.3.0
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user