Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion example/pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ packages:
path: ".."
relative: true
source: path
version: "0.2.0"
version: "0.3.0"
flutter_lints:
dependency: "direct dev"
description:
Expand Down
2 changes: 2 additions & 0 deletions lib/flutter_licensing.dart
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
library;

export 'src/clock.dart';
export 'src/device_identity.dart';
export 'src/entitlements.dart';
export 'src/key_registry.dart';
export 'src/license.dart';
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';
33 changes: 33 additions & 0 deletions lib/src/device_identity.dart
Original file line number Diff line number Diff line change
@@ -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<String> getOrCreate() async {
final stored = await _storage.read(key: storageKey);
if (stored != null && stored.isNotEmpty) return stored;
final random = Random.secure();
final bytes = List<int>.generate(32, (_) => random.nextInt(256));
final created = base64UrlEncode(bytes).replaceAll('=', '');
await _storage.write(key: storageKey, value: created);
return created;
}

Future<String> hash() async {
final digest = await Sha256().hash(utf8.encode(await getOrCreate()));
return digest.bytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join();
}
}
6 changes: 5 additions & 1 deletion lib/src/license.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -19,4 +22,5 @@ final class License {
final DateTime notBefore;
final DateTime expiresAt;
final String keyId;
final String? deviceIdHash;
}
70 changes: 62 additions & 8 deletions lib/src/licensing.dart
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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;
Expand All @@ -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<LicenseValidationResult> 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!;
Expand All @@ -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<LicenseSynchronizationResult> 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 = <LicenseValidationResult>[];
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<bool> hasValidLicense() async => (await validateLicense()).isValid;
Future<LicenseAccessResult> checkFeature(LicenseFeature feature) async {
if (entitlements.isFree(feature)) {
Expand All @@ -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
Expand All @@ -113,3 +157,13 @@ final class FlutterLicensing {
_lastResult = null;
}
}

final class LicenseSynchronizationResult {
const LicenseSynchronizationResult(this.status, this.validations);
final LicenseSyncStatus status;
final List<LicenseValidationResult> validations;
bool get isVerified =>
status == LicenseSyncStatus.valid &&
validations.isNotEmpty &&
validations.every((validation) => validation.isValid);
}
60 changes: 60 additions & 0 deletions lib/src/sync.dart
Original file line number Diff line number Diff line change
@@ -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<String> 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<LicenseSyncPayload> 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<String, dynamic> ||
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 = <String>[];
for (final item in decoded['licenses'] as List) {
if (item is! Map<String, dynamic> || 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';
}
41 changes: 34 additions & 7 deletions lib/src/validation.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ enum LicenseValidationStatus {
unknownKey,
invalidSignature,
invalidProduct,
idMismatch,
deviceIdMismatch,
@Deprecated('Use idMismatch')
coreIdMismatch,
notYetValid,
expired
Expand Down Expand Up @@ -46,7 +49,9 @@ final class LicenseVerifier {
final Ed25519 _ed25519 = Ed25519();

Future<LicenseValidationResult> 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<String, dynamic> ||
Expand Down Expand Up @@ -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();
Expand All @@ -116,7 +130,7 @@ final class LicenseVerifier {
}

License? _parseTrusted(Map<String, dynamic> value) {
const exact = {
const required = {
'v',
'id',
'license_id',
Expand All @@ -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'],
Expand All @@ -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 ||
Expand All @@ -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 ||
Expand All @@ -170,12 +189,20 @@ 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;
}
}

bool _validString(Object? value, int max) =>
value is String && value.isNotEmpty && value.length <= max;

Future<String> _hashDeviceId(String value) async {
final digest = await Sha256().hash(utf8.encode(value));
return digest.bytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join();
}
}
Loading
Loading