From 8c3f1109582f61f2e6ac66eed238e9385f4a31d4 Mon Sep 17 00:00:00 2001 From: Rastislav Date: Sun, 30 Aug 2026 18:53:12 +0200 Subject: [PATCH] update --- CHANGELOG.md | 7 ++++ README.md | 14 ++++++- example/pubspec.lock | 2 +- lib/flutter_licensing.dart | 2 + lib/src/device_identity.dart | 33 ++++++++++++++++ lib/src/license.dart | 6 ++- lib/src/licensing.dart | 70 +++++++++++++++++++++++++++++---- lib/src/sync.dart | 60 ++++++++++++++++++++++++++++ lib/src/validation.dart | 41 +++++++++++++++---- pubspec.yaml | 3 +- test/license_verifier_test.dart | 51 ++++++++++++++++++++++++ 11 files changed, 270 insertions(+), 19 deletions(-) create mode 100644 lib/src/device_identity.dart create mode 100644 lib/src/sync.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c3f285..ef23734 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## 0.3.0 + +- Add offline-first AtomCyou synchronization for active licenses. +- Verify every downloaded certificate before updating secure local storage. +- Add independent signed customer and device identity validation. +- Add secure random installation identities and SHA-256 device binding. + ## 0.2.0 - Add explicit separate and replace licensing models. diff --git a/README.md b/README.md index d06e4fd..d3912bd 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,17 @@ final licensing = FlutterLicensing( verifier: verifier, storage: const SecureLicenseStorage(), entitlements: rules, + expectedId: customerId, + expectedDeviceId: deviceId, ); await licensing.initialize(); +final syncResult = await licensing.sync( + AtomCyouSyncClient( + baseUri: Uri.parse('https://atom.cyou'), + project: 'my-project', + ), + customerId, +); final result = await licensing.importLicense( certificateText, model: LicenseModel.replace, @@ -56,7 +65,10 @@ Keys may instead be supplied as 32 raw bytes, base64/base64url, or an RFC 8410 P - `product` must exactly match the running package/bundle identifier. - Arbitrary non-negative `planId` values are valid; optional names never affect validity. - Free features work without a license. -- Optional `expectedCoreId` prevents sharing between identities. +- The signed payload includes the Core/customer identity as `id` and may include `device_id_hash`, a SHA-256 digest of a separate high-entropy installation ID. +- `SecureDeviceIdentity` generates a random 256-bit installation ID and retains it in platform secure storage. Send that value as CorePort `deviceid`; AtomCyou stores and returns only its signed hash. +- `expectedId` verifies the owner. `expectedDeviceId` is hashed locally before comparison with `device_id_hash`; it does not replace the owner. Avoid predictable hardware identifiers because an unsalted hash of low-entropy data can be guessed. +- `sync` downloads only active AtomCyou certificates and verifies every certificate before updating secure local storage. Network errors preserve offline state; authoritative `none` and `suspended` responses remove it. - Import validates before storage. Same-plan certificates extend only when their expiration is later. - `LicenseModel.separate` (the default) keeps a current license when a different plan is imported. - `LicenseModel.replace` immediately replaces a different plan. A higher plan ID is an upgrade and a lower plan ID is a downgrade; both behave identically during import. diff --git a/example/pubspec.lock b/example/pubspec.lock index bdfcfc5..c59ab48 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -92,7 +92,7 @@ packages: path: ".." relative: true source: path - version: "0.2.0" + version: "0.3.0" flutter_lints: dependency: "direct dev" description: diff --git a/lib/flutter_licensing.dart b/lib/flutter_licensing.dart index 025c0fc..35b98a3 100644 --- a/lib/flutter_licensing.dart +++ b/lib/flutter_licensing.dart @@ -1,6 +1,7 @@ library; export 'src/clock.dart'; +export 'src/device_identity.dart'; export 'src/entitlements.dart'; export 'src/key_registry.dart'; export 'src/license.dart'; @@ -8,4 +9,5 @@ export 'src/licensing.dart'; export 'src/plan_registry.dart'; export 'src/product_identifier.dart'; export 'src/storage.dart'; +export 'src/sync.dart'; export 'src/validation.dart'; diff --git a/lib/src/device_identity.dart b/lib/src/device_identity.dart new file mode 100644 index 0000000..b2e3056 --- /dev/null +++ b/lib/src/device_identity.dart @@ -0,0 +1,33 @@ +import 'dart:convert'; +import 'dart:math'; +import 'package:cryptography/cryptography.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// Creates and retains an opaque installation identifier in secure storage. +/// This is not a hardware identifier and changes when secure app data is erased. +final class SecureDeviceIdentity { + const SecureDeviceIdentity( + {FlutterSecureStorage storage = const FlutterSecureStorage(), + this.storageKey = 'flutter_licensing.device_id'}) + : _storage = storage; + + final FlutterSecureStorage _storage; + final String storageKey; + + Future getOrCreate() async { + final stored = await _storage.read(key: storageKey); + if (stored != null && stored.isNotEmpty) return stored; + final random = Random.secure(); + final bytes = List.generate(32, (_) => random.nextInt(256)); + final created = base64UrlEncode(bytes).replaceAll('=', ''); + await _storage.write(key: storageKey, value: created); + return created; + } + + Future hash() async { + final digest = await Sha256().hash(utf8.encode(await getOrCreate())); + return digest.bytes + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join(); + } +} diff --git a/lib/src/license.dart b/lib/src/license.dart index 01bb6ba..9c83d59 100644 --- a/lib/src/license.dart +++ b/lib/src/license.dart @@ -9,8 +9,11 @@ final class License { required this.issuedAt, required this.notBefore, required this.expiresAt, - required this.keyId}); + required this.keyId, + this.deviceIdHash}); final int version; + String get id => coreId; + @Deprecated('Use id') final String coreId; final String licenseId; final String product; @@ -19,4 +22,5 @@ final class License { final DateTime notBefore; final DateTime expiresAt; final String keyId; + final String? deviceIdHash; } diff --git a/lib/src/licensing.dart b/lib/src/licensing.dart index 2de7628..a321dcd 100644 --- a/lib/src/licensing.dart +++ b/lib/src/licensing.dart @@ -1,6 +1,7 @@ import 'entitlements.dart'; import 'license.dart'; import 'storage.dart'; +import 'sync.dart'; import 'validation.dart'; /// Controls how an imported license for a different plan is handled. @@ -18,13 +19,21 @@ final class FlutterLicensing { {required LicenseVerifier verifier, required LicenseStorage storage, FeatureEntitlements? entitlements, - this.expectedCoreId}) + this.expectedId, + this.expectedDeviceId, + @Deprecated('Use expectedId') this.expectedCoreId}) : _verifier = verifier, _storage = storage, - entitlements = entitlements ?? FeatureEntitlements(); + entitlements = entitlements ?? FeatureEntitlements(), + assert(expectedId == null || + expectedCoreId == null || + expectedId == expectedCoreId); final LicenseVerifier _verifier; final LicenseStorage _storage; final FeatureEntitlements entitlements; + final String? expectedId; + final String? expectedDeviceId; + @Deprecated('Use expectedId') final String? expectedCoreId; String? _certificate; LicenseValidationResult? _lastResult; @@ -41,18 +50,21 @@ final class FlutterLicensing { return _lastResult = const LicenseValidationResult(LicenseValidationStatus.malformed); } - return _lastResult = - await _verifier.verify(_certificate!, expectedCoreId: expectedCoreId); + return _lastResult = await _verifier.verify(_certificate!, + expectedId: expectedId ?? expectedCoreId, + expectedDeviceId: expectedDeviceId); } Future importLicense(String certificate, {LicenseModel model = LicenseModel.separate}) async { - final candidate = - await _verifier.verify(certificate, expectedCoreId: expectedCoreId); + final candidate = await _verifier.verify(certificate, + expectedId: expectedId ?? expectedCoreId, + expectedDeviceId: expectedDeviceId); if (!candidate.isValid) return candidate; if (_certificate != null) { - final existing = - await _verifier.verify(_certificate!, expectedCoreId: expectedCoreId); + final existing = await _verifier.verify(_certificate!, + expectedId: expectedId ?? expectedCoreId, + expectedDeviceId: expectedDeviceId); if (existing.isValid) { final oldLicense = existing.license!; final newLicense = candidate.license!; @@ -77,6 +89,35 @@ final class FlutterLicensing { } String? exportLicense() => _certificate; + + /// Downloads active licenses, verifies every certificate, and stores the + /// newest verified certificate. A successful `none` or `suspended` response + /// removes the local certificate; network failures leave offline state intact. + Future sync( + AtomCyouSyncClient client, String customerId) async { + final payload = await client.download(customerId); + if (payload.status != LicenseSyncStatus.valid) { + await deleteLicense(); + return LicenseSynchronizationResult(payload.status, const []); + } + final validations = []; + for (final certificate in payload.certificates) { + final validation = await _verifier.verify(certificate, + expectedId: expectedId ?? expectedCoreId, + expectedDeviceId: expectedDeviceId); + validations.add(validation); + } + if (validations.any((validation) => !validation.isValid)) { + return LicenseSynchronizationResult(payload.status, validations); + } + if (payload.certificates.isNotEmpty) { + await _storage.saveLicense(payload.certificates.first); + _certificate = payload.certificates.first; + _lastResult = validations.first; + } + return LicenseSynchronizationResult(payload.status, validations); + } + Future hasValidLicense() async => (await validateLicense()).isValid; Future checkFeature(LicenseFeature feature) async { if (entitlements.isFree(feature)) { @@ -98,6 +139,9 @@ final class FlutterLicensing { LicenseValidationStatus.notYetValid => LicenseAccessStatus.notYetValid, LicenseValidationStatus.invalidProduct => LicenseAccessStatus.invalidProduct, + LicenseValidationStatus.idMismatch => LicenseAccessStatus.coreIdMismatch, + LicenseValidationStatus.deviceIdMismatch => + LicenseAccessStatus.coreIdMismatch, LicenseValidationStatus.coreIdMismatch => LicenseAccessStatus.coreIdMismatch, _ => LicenseAccessStatus.invalidLicense @@ -113,3 +157,13 @@ final class FlutterLicensing { _lastResult = null; } } + +final class LicenseSynchronizationResult { + const LicenseSynchronizationResult(this.status, this.validations); + final LicenseSyncStatus status; + final List validations; + bool get isVerified => + status == LicenseSyncStatus.valid && + validations.isNotEmpty && + validations.every((validation) => validation.isValid); +} diff --git a/lib/src/sync.dart b/lib/src/sync.dart new file mode 100644 index 0000000..f7cb28a --- /dev/null +++ b/lib/src/sync.dart @@ -0,0 +1,60 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; + +enum LicenseSyncStatus { valid, none, suspended } + +final class LicenseSyncPayload { + const LicenseSyncPayload({required this.status, required this.certificates}); + final LicenseSyncStatus status; + final List certificates; +} + +/// Downloads active certificates from an AtomCyou project. +/// Certificates remain untrusted until [FlutterLicensing.sync] verifies them. +final class AtomCyouSyncClient { + AtomCyouSyncClient( + {required this.baseUri, required this.project, http.Client? client}) + : _client = client ?? http.Client(); + + final Uri baseUri; + final String project; + final http.Client _client; + + Future download(String customerId) async { + final uri = baseUri.resolve( + '/api/v1/${Uri.encodeComponent(project)}/sync/${Uri.encodeComponent(customerId)}'); + final response = + await _client.get(uri, headers: const {'accept': 'application/json'}); + if (response.statusCode != 200 && response.statusCode != 402) { + throw LicenseSyncException( + 'AtomCyou returned HTTP ${response.statusCode}'); + } + final decoded = jsonDecode(response.body); + if (decoded is! Map || + decoded['status'] is! String || + decoded['licenses'] is! List) { + throw const LicenseSyncException('Invalid AtomCyou sync response'); + } + final status = switch (decoded['status']) { + 'valid' => LicenseSyncStatus.valid, + 'none' => LicenseSyncStatus.none, + 'suspended' => LicenseSyncStatus.suspended, + _ => throw const LicenseSyncException('Unknown AtomCyou sync status') + }; + final certificates = []; + for (final item in decoded['licenses'] as List) { + if (item is! Map || item['certificate'] is! String) { + throw const LicenseSyncException('Invalid certificate entry'); + } + certificates.add(item['certificate'] as String); + } + return LicenseSyncPayload(status: status, certificates: certificates); + } +} + +final class LicenseSyncException implements Exception { + const LicenseSyncException(this.message); + final String message; + @override + String toString() => 'LicenseSyncException: $message'; +} diff --git a/lib/src/validation.dart b/lib/src/validation.dart index b0bc217..857f246 100644 --- a/lib/src/validation.dart +++ b/lib/src/validation.dart @@ -13,6 +13,9 @@ enum LicenseValidationStatus { unknownKey, invalidSignature, invalidProduct, + idMismatch, + deviceIdMismatch, + @Deprecated('Use idMismatch') coreIdMismatch, notYetValid, expired @@ -46,7 +49,9 @@ final class LicenseVerifier { final Ed25519 _ed25519 = Ed25519(); Future verify(String certificate, - {String? expectedCoreId}) async { + {String? expectedId, + String? expectedDeviceId, + @Deprecated('Use expectedId') String? expectedCoreId}) async { try { final envelope = jsonDecode(certificate); if (envelope is! Map || @@ -95,8 +100,17 @@ final class LicenseVerifier { return LicenseValidationResult(LicenseValidationStatus.invalidProduct, license: parsed); } - if (expectedCoreId != null && parsed.coreId != expectedCoreId) { - return LicenseValidationResult(LicenseValidationStatus.coreIdMismatch, + final requiredId = expectedId ?? expectedCoreId; + if (requiredId != null && parsed.id != requiredId) { + return LicenseValidationResult( + expectedId != null + ? LicenseValidationStatus.idMismatch + : LicenseValidationStatus.coreIdMismatch, + license: parsed); + } + if (expectedDeviceId != null && + parsed.deviceIdHash != await _hashDeviceId(expectedDeviceId)) { + return LicenseValidationResult(LicenseValidationStatus.deviceIdMismatch, license: parsed); } final now = await _clock.now(); @@ -116,7 +130,7 @@ final class LicenseVerifier { } License? _parseTrusted(Map value) { - const exact = { + const required = { 'v', 'id', 'license_id', @@ -127,8 +141,9 @@ final class LicenseVerifier { 'expires_at', 'key_id' }; - if (value.keys.toSet().difference(exact).isNotEmpty || - exact.difference(value.keys.toSet()).isNotEmpty) { + const allowed = {...required, 'device_id_hash'}; + if (value.keys.toSet().difference(allowed).isNotEmpty || + required.difference(value.keys.toSet()).isNotEmpty) { return null; } final v = value['v'], @@ -140,6 +155,7 @@ final class LicenseVerifier { notBefore = value['not_before'], expires = value['expires_at'], key = value['key_id']; + final deviceHash = value['device_id_hash']; if (v is! int || plan is! int || plan < 0 || @@ -150,6 +166,9 @@ final class LicenseVerifier { !_validString(id, 256) || !_validString(product, 255) || !_validString(key, 128) || + (deviceHash != null && + (deviceHash is! String || + !RegExp(r'^[a-f0-9]{64}$').hasMatch(deviceHash))) || issued < 0 || notBefore < 0 || expires < 0 || @@ -170,7 +189,8 @@ final class LicenseVerifier { isUtc: true), expiresAt: DateTime.fromMillisecondsSinceEpoch(expires * 1000, isUtc: true), - keyId: key as String); + keyId: key as String, + deviceIdHash: deviceHash as String?); } on RangeError { return null; } @@ -178,4 +198,11 @@ final class LicenseVerifier { bool _validString(Object? value, int max) => value is String && value.isNotEmpty && value.length <= max; + + Future _hashDeviceId(String value) async { + final digest = await Sha256().hash(utf8.encode(value)); + return digest.bytes + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join(); + } } diff --git a/pubspec.yaml b/pubspec.yaml index f19be74..0338d75 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_licensing description: Offline Ed25519 license verification and feature entitlements for Flutter applications. -version: 0.2.0 +version: 0.3.0 homepage: https://github.com/bchainhub/flutter_licensing repository: https://github.com/bchainhub/flutter_licensing issue_tracker: https://github.com/bchainhub/flutter_licensing/issues @@ -14,6 +14,7 @@ dependencies: flutter: sdk: flutter flutter_secure_storage: ^11.0.0 + http: ^1.6.0 package_info_plus: ^10.2.0 package_info_plus_platform_interface: ^4.1.0 diff --git a/test/license_verifier_test.dart b/test/license_verifier_test.dart index 60da17b..15c25b6 100644 --- a/test/license_verifier_test.dart +++ b/test/license_verifier_test.dart @@ -2,6 +2,8 @@ import 'dart:convert'; import 'package:cryptography/cryptography.dart'; import 'package:flutter_licensing/flutter_licensing.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; void main() { late Ed25519 algorithm; @@ -27,10 +29,16 @@ void main() { Future certificate( {String product = 'com.application.app', int planId = 67, + String? deviceId, DateTime? expires}) async { final payload = utf8.encode(jsonEncode({ 'v': 1, 'id': 'cb_test', + if (deviceId != null) + 'device_id_hash': (await Sha256().hash(utf8.encode(deviceId))) + .bytes + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join(), 'license_id': 'lic_test', 'product': product, 'planId': planId, @@ -54,6 +62,21 @@ void main() { final result = await verifier.verify(await certificate(planId: 9001)); expect(result.status, LicenseValidationStatus.valid); expect(result.license!.planId, 9001); + expect(result.license!.id, 'cb_test'); + }); + + test('cryptographically binds customer and optional device IDs', () async { + final encoded = await certificate(deviceId: 'device-123'); + expect((await verifier.verify(encoded, expectedId: 'cb_test')).status, + LicenseValidationStatus.valid); + expect((await verifier.verify(encoded, expectedId: 'another-user')).status, + LicenseValidationStatus.idMismatch); + expect( + (await verifier.verify(encoded, expectedDeviceId: 'device-123')).status, + LicenseValidationStatus.valid); + expect( + (await verifier.verify(encoded, expectedDeviceId: 'device-456')).status, + LicenseValidationStatus.deviceIdMismatch); }); test('distinguishes product mismatch and expiration', () async { @@ -81,6 +104,34 @@ void main() { LicenseAccessStatus.allowed); }); + test('sync downloads and validates active licenses before storage', () async { + final encoded = await certificate(deviceId: 'device-123'); + final client = AtomCyouSyncClient( + baseUri: Uri.parse('https://atom.cyou'), + project: 'my-project', + client: MockClient((request) async { + expect(request.url.path, '/api/v1/my-project/sync/cb_test'); + return http.Response( + jsonEncode({ + 'status': 'valid', + 'licenses': [ + {'certificate': encoded} + ] + }), + 200); + })); + final licensing = FlutterLicensing( + verifier: verifier, + storage: InMemoryLicenseStorage(), + expectedId: 'cb_test', + expectedDeviceId: 'device-123'); + + final result = await licensing.sync(client, 'cb_test'); + + expect(result.isVerified, isTrue); + expect(licensing.currentLicense!.deviceIdHash, hasLength(64)); + }); + test('separate licensing keeps a different current plan', () async { final storage = InMemoryLicenseStorage(); final licensing = FlutterLicensing(verifier: verifier, storage: storage);