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: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<void> _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<void> _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<void> _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<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 {
|
||||
if (purchase.pendingCompletePurchase) {
|
||||
|
||||
Reference in New Issue
Block a user