Initial commit (Security Hardened Slate)

This commit is contained in:
esefxdz committed 2026-05-29 14:42:24 +03:00
commit 157f60aad2
65 files changed
+3735

No files matched your search

+153
View File
@@ -0,0 +1,153 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
import 'package:paladinvpn/logic/crypto_service.dart';
import 'package:paladinvpn/logic/hivemind_service.dart';
import 'package:paladinvpn/logic/app_config.dart';
/// AdManager manages the lifecycle of rewarded video ads.
///
/// It acts as a strict gatekeeper: users MUST watch an ad to connect.
/// If an ad is not ready when they click connect, it forces them to wait
/// while it loads. If they dismiss the ad early, they do not get the connection.
class AdManager extends ChangeNotifier {
RewardedAd? _rewardedAd;
bool _isAdLoaded = false;
bool get isAdLoaded => _isAdLoaded;
bool _isAdLoading = false;
bool get isAdLoading => _isAdLoading;
Completer<bool>? _loadCompleter;
// Google-provided test ad unit
static String get _adUnitId => AppConfig.adUnitId;
AdManager() {
_preloadAd();
}
/// Internal helper to load an ad and return a Future when it's ready
Future<bool> _preloadAd() async {
// If already loaded, return instantly
if (_isAdLoaded) return true;
// If we're already loading one right now, wait for that same request to finish
if (_isAdLoading) return _loadCompleter?.future ?? Future.value(false);
_isAdLoading = true;
_loadCompleter = Completer<bool>();
notifyListeners();
if (kIsWeb) {
await Future.delayed(const Duration(milliseconds: 500));
_isAdLoaded = true;
_isAdLoading = false;
notifyListeners();
_loadCompleter?.complete(true);
return true;
}
RewardedAd.load(
adUnitId: _adUnitId,
request: const AdRequest(),
rewardedAdLoadCallback: RewardedAdLoadCallback(
onAdLoaded: (ad) {
_rewardedAd = ad;
_isAdLoaded = true;
_isAdLoading = false;
notifyListeners();
if (!(_loadCompleter?.isCompleted ?? true)) {
_loadCompleter?.complete(true);
}
},
onAdFailedToLoad: (error) {
debugPrint('Rewarded ad failed to load: ${error.message}');
_isAdLoaded = false;
_isAdLoading = false;
notifyListeners();
if (!(_loadCompleter?.isCompleted ?? true)) {
_loadCompleter?.complete(false);
}
},
),
);
return _loadCompleter!.future;
}
/// Displays the rewarded video ad to the user.
Future<bool> showAd(String adType) async {
// 1. Ensure the ad is fully loaded before trying to show it
if (!_isAdLoaded || _rewardedAd == null) {
final loaded = await _preloadAd();
if (!loaded || _rewardedAd == null) {
debugPrint('[AdManager] Cannot show ad, failed to load.');
return false;
}
}
// 2. Fetch device credentials for Server-Side Verification (SSV)
final deviceId = await CryptoService.getDeviceId();
final keys = await CryptoService.getOrCreateKeys();
final pubKey = keys['publicKey']!;
// 3. Attach custom data to the ad so Google sends it back to our Hivemind server
_rewardedAd!.serverSideVerificationOptions = ServerSideVerificationOptions(
customData: jsonEncode({
'device_id': deviceId,
'public_key': pubKey,
'ad_type': adType,
}),
);
Completer<bool> rewardCompleter = Completer<bool>();
// 4. Setup full screen callbacks to handle dismissal and errors
_rewardedAd!.fullScreenContentCallback = FullScreenContentCallback(
onAdShowedFullScreenContent: (ad) => debugPrint('[AdManager] Ad showing.'),
onAdDismissedFullScreenContent: (ad) {
debugPrint('[AdManager] Ad dismissed.');
ad.dispose();
_isAdLoaded = false;
_rewardedAd = null;
// Start loading the next ad immediately
_preloadAd();
if (!rewardCompleter.isCompleted) {
rewardCompleter.complete(false); // User closed before earning reward
}
},
onAdFailedToShowFullScreenContent: (ad, error) {
debugPrint('[AdManager] Ad failed to show: $error');
ad.dispose();
_isAdLoaded = false;
_rewardedAd = null;
if (!rewardCompleter.isCompleted) {
rewardCompleter.complete(false);
}
},
);
// 5. Show the ad
await _rewardedAd!.show(
onUserEarnedReward: (AdWithoutView ad, RewardItem reward) {
debugPrint('[AdManager] Reward earned: ${reward.amount} ${reward.type}');
// The SSV callback will hit our server independently. We just need to signal the UI
// that the ad was successfully completed.
if (!rewardCompleter.isCompleted) {
rewardCompleter.complete(true);
}
},
);
return rewardCompleter.future;
}
@override
void dispose() {
_rewardedAd?.dispose();
super.dispose();
}
}
+44
View File
@@ -0,0 +1,44 @@
// =============================================================================
// app_config.example.dart — TEMPLATE FOR SERVER SETTINGS
// ─────────────────────────────────────────────────────────────────────────────
// Rename this file to `app_config.dart` and fill in your real values below.
// Do not commit your real `app_config.dart` to GitHub!
// =============================================================================
class AppConfig {
// ── STEP 1: Paste your Hetzner server IP here ─────────────────────────────
static const String serverIp = '127.0.0.1'; // REPLACE WITH REAL IP
// ── STEP 2: WireGuard UDP traffic port (default: 51820) ───────────────────
static const String wgPort = '4433';
// ── STEP 3: AmneziaWG DPI Obfuscation Settings ───────────────────────────
// These parameters must match exactly what your amnezia-wg-easy server expects.
static const int awgJc = 4;
static const int awgJmin = 20;
static const int awgJmax = 100;
static const int awgS1 = 15;
static const int awgS2 = 23;
static const int awgH1 = 1;
static const int awgH2 = 2;
static const int awgH3 = 3;
static const int awgH4 = 4;
// ── STEP 4: AdMob Rewarded Video ID ──────────────────────────────────────
static const String adUnitId = 'ca-app-pub-0000000000000000/0000000000'; // REPLACE WITH REAL AD UNIT
// ── STEP 5: Network & Tunnel Settings ────────────────────────────────────
static const String dnsServers = '1.1.1.1, 1.0.0.1';
static const String allowedIps = '0.0.0.0/0, ::/0';
static const int mtu = 1280;
static const int persistentKeepalive = 25;
static const int hivemindPort = 5000;
// ─────────────────────────────────────────────────────────────────────────
// Nothing below this line needs to be changed.
// ── STEP 1: Hivemind Server API ──────────────────────────────────────────
static String get hivemindApiBase => 'https://yourdomain.duckdns.org/api'; // REPLACE WITH REAL DOMAIN
/// The full server address passed to the WireGuard tunnel (IP:port).
static String get serverEndpoint => '$serverIp:$wgPort';
}
+46
View File
@@ -0,0 +1,46 @@
import 'dart:convert';
import 'package:cryptography/cryptography.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:uuid/uuid.dart';
class CryptoService {
static const String _privKeyPref = 'wg_private_key';
static const String _pubKeyPref = 'wg_public_key';
static const String _deviceIdPref = 'device_uuid';
static const _storage = FlutterSecureStorage();
// ── Get or create the persistent device UUID ──────────────────────────────
static Future<String> getDeviceId() async {
final existing = await _storage.read(key: _deviceIdPref);
if (existing != null) return existing;
final newId = const Uuid().v4();
await _storage.write(key: _deviceIdPref, value: newId);
return newId;
}
// ── Get or create the X25519 keypair for WireGuard ────────────────────────
static Future<Map<String, String>> getOrCreateKeys() async {
String? privBase64 = await _storage.read(key: _privKeyPref);
String? pubBase64 = await _storage.read(key: _pubKeyPref);
if (privBase64 != null && pubBase64 != null) {
return {'privateKey': privBase64, 'publicKey': pubBase64};
}
final algorithm = X25519();
final keyPair = await algorithm.newKeyPair();
final privateKeyBytes = await keyPair.extractPrivateKeyBytes();
final publicKey = await keyPair.extractPublicKey();
privBase64 = base64Encode(privateKeyBytes);
pubBase64 = base64Encode(publicKey.bytes);
await _storage.write(key: _privKeyPref, value: privBase64);
await _storage.write(key: _pubKeyPref, value: pubBase64);
return {'privateKey': privBase64, 'publicKey': pubBase64};
}
}
+102
View File
@@ -0,0 +1,102 @@
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:paladinvpn/logic/app_config.dart';
import 'package:paladinvpn/logic/crypto_service.dart';
class HivemindService {
/// Polls the Hivemind server until the session is ready or timeout occurs.
/// Returns the WireGuard config string.
static Future<String> fetchConfigWithPolling() async {
final deviceId = await CryptoService.getDeviceId();
final keys = await CryptoService.getOrCreateKeys();
final privKey = keys['privateKey']!;
final url = Uri.parse('${AppConfig.hivemindApiBase}/session/status?device_id=$deviceId');
// ── TEMPORARY AD BYPASS HACK FOR TESTING ──
// Since you disabled the crypto signature check on the Python server,
// we can just send a fake "Ad Watched" ping from the app directly.
try {
final customData = jsonEncode({
'device_id': deviceId,
'public_key': keys['publicKey']!,
'ad_type': 'main_ad',
});
final fakeAdmobPingUrl = Uri.parse(
'${AppConfig.hivemindApiBase}/admob/callback?signature=test&key_id=test&custom_data=${Uri.encodeComponent(customData)}');
await http.get(fakeAdmobPingUrl).timeout(const Duration(seconds: 2));
} catch (_) {}
// ──────────────────────────────────────────
int attempts = 0;
while (attempts < 15) {
try {
final response = await http.get(url).timeout(const Duration(seconds: 3));
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
// Debugging log to see exactly what the server returned
print('[HivemindService] Polling response: ${response.body}');
if (data['active'] == true && data['client_ip'] != null && data['server_pubkey'] != null) {
final clientIp = data['client_ip'];
final serverPubkey = data['server_pubkey'];
// Construct the WireGuard config locally
// I CHANGED IPS MAY BREAK STUFF
final configText = '''
[Interface]
PrivateKey = $privKey
Address = $clientIp/24
DNS = ${AppConfig.dnsServers}
MTU = ${AppConfig.mtu}
Jc = ${AppConfig.awgJc}
Jmin = ${AppConfig.awgJmin}
Jmax = ${AppConfig.awgJmax}
S1 = ${AppConfig.awgS1}
S2 = ${AppConfig.awgS2}
H1 = ${AppConfig.awgH1}
H2 = ${AppConfig.awgH2}
H3 = ${AppConfig.awgH3}
H4 = ${AppConfig.awgH4}
[Peer]
PublicKey = $serverPubkey
AllowedIPs = ${AppConfig.allowedIps}
Endpoint = ${AppConfig.serverEndpoint}
PersistentKeepalive = ${AppConfig.persistentKeepalive}
''';
return configText;
}
} else {
print('[HivemindService] Non-200 status: ${response.statusCode}');
}
} catch (e) {
print('[HivemindService] Polling exception: $e');
// Ignore network errors and keep polling
}
attempts++;
await Future.delayed(const Duration(seconds: 2));
}
throw Exception("Session not activated. S2S callback failed or timed out.");
}
/// Disconnects the session (cleans up on server)
static Future<void> disconnectAndCleanup() async {
final deviceId = await CryptoService.getDeviceId();
final url = Uri.parse('${AppConfig.hivemindApiBase}/session/stop');
try {
await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'device_id': deviceId}),
).timeout(const Duration(seconds: 3));
} catch (_) {
// Best effort cleanup
}
}
}
+170
View File
@@ -0,0 +1,170 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:paladinvpn/logic/app_config.dart';
import 'package:paladinvpn/logic/vpn_connection.dart';
import 'package:paladinvpn/logic/crypto_service.dart';
/// SessionTimer acts as the client-side session authority for the VPN tunnel.
///
/// It connects to the Hivemind API on the server to sync the true remaining
/// time, data quotas, speed, and throttling status.
class SessionTimer extends ChangeNotifier {
Timer? _timer;
int _tickCount = 0;
final VpnConnection vpnConnection;
// ── Session State ──
int _remainingSeconds = 0;
int _quotaBytes = 0;
int _usedBytes = 0;
bool _isThrottled = false;
// ── Speed State ──
int _lastUsedBytes = 0;
double _currentSpeedKbps = 0.0; // Total speed (RX+TX)
SessionTimer({required this.vpnConnection});
int get remaining => _remainingSeconds;
bool get isRunning => _timer != null && _timer!.isActive;
bool get isExpired => _remainingSeconds <= 0 && !isRunning;
int get quotaBytes => _quotaBytes;
int get usedBytes => _usedBytes;
int get remainingBytes => _quotaBytes > _usedBytes ? _quotaBytes - _usedBytes : 0;
bool get isThrottled => _isThrottled;
double get currentSpeedKbps => _currentSpeedKbps;
String get formatted {
final h = (_remainingSeconds ~/ 3600).toString().padLeft(2, '0');
final m = ((_remainingSeconds % 3600) ~/ 60).toString().padLeft(2, '0');
final s = (_remainingSeconds % 60).toString().padLeft(2, '0');
return '$h:$m:$s';
}
String get formattedDataRemaining {
if (_quotaBytes == 0) return '0.00 GB';
final gb = remainingBytes / (1024 * 1024 * 1024);
return '${gb.toStringAsFixed(2)} GB';
}
double get progress {
// Max duration is generally 60 or 90 minutes. We'll base the ring purely on data usage or just standard time
// For UI simplicity, we'll return data progress:
if (_quotaBytes == 0) return 0.0;
return (_quotaBytes - _usedBytes) / _quotaBytes;
}
Future<void> start(String adType) async {
_remainingSeconds = 3600; // optimistic start
_quotaBytes = 2 * 1024 * 1024 * 1024;
_usedBytes = 0;
_lastUsedBytes = 0;
_currentSpeedKbps = 0.0;
_isThrottled = false;
_tickCount = 0;
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 1), _tick);
// Kick off an immediate sync so the UI shows real data right away
_syncWithHivemind();
}
void _tick(Timer t) {
if (_remainingSeconds > 0) {
_remainingSeconds--;
}
_tickCount++;
// Poll the server every 3 seconds to get live stats
if (_tickCount % 3 == 0) {
_syncWithHivemind();
}
notifyListeners();
}
Future<void> _syncWithHivemind() async {
try {
final deviceId = await CryptoService.getDeviceId();
final url = Uri.parse('${AppConfig.hivemindApiBase}/session/status?device_id=$deviceId');
final response = await http.get(url).timeout(const Duration(seconds: 3));
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
final bool active = data['active'] ?? false;
if (!active) {
// Server killed the session!
stop();
vpnConnection.disconnect();
return;
}
_remainingSeconds = data['expires_in_seconds'] ?? _remainingSeconds;
_quotaBytes = data['quota_bytes'] ?? _quotaBytes;
_usedBytes = data['used_bytes'] ?? _usedBytes;
_isThrottled = data['is_throttled'] ?? false;
// Calculate speed over the polling interval
final int deltaBytes = _usedBytes - _lastUsedBytes;
if (_lastUsedBytes > 0 && deltaBytes > 0) {
_currentSpeedKbps = (deltaBytes / 3) / 1024;
} else {
_currentSpeedKbps = 0.0;
}
_lastUsedBytes = _usedBytes;
notifyListeners();
} else if (response.statusCode == 404) {
// Peer not found on server (expired/deleted natively)
stop();
vpnConnection.disconnect();
}
} catch (e) {
debugPrint('Hivemind sync error: $e');
// If we can't reach the server, just let the local timer run
}
}
Future<void> addBonusTime() async {
final ip = vpnConnection.internalIp;
if (ip != null) {
try {
final deviceId = await CryptoService.getDeviceId();
await http.post(
Uri.parse('${AppConfig.hivemindApiBase}/session/start'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'ip': ip,
'device_id': deviceId,
'ad_type': 'bonus_ad'
}),
).timeout(const Duration(seconds: 5));
// Immediately sync to fetch the updated limits and un-throttled status
_syncWithHivemind();
} catch (e) {
debugPrint('Failed to add bonus time with Hivemind: $e');
}
}
}
void stop() {
_timer?.cancel();
_timer = null;
_remainingSeconds = 0;
_currentSpeedKbps = 0.0;
notifyListeners();
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
}
+216
View File
@@ -0,0 +1,216 @@
// =============================================================================
// vpn_connection.dart — WireGuard Tunnel Manager
// ─────────────────────────────────────────────────────────────────────────────
// This file manages the WireGuard tunnel on the Android device.
// It is the bridge between the UI (the big connect button) and the actual
// VPN tunnel running inside the Android operating system.
//
// HOW IT WORKS:
// 1. When connect() is called, it first asks WgApiService to fetch the config
// file for this device from the server (see wg_api_service.dart).
// 2. It passes that config text to the wireguard_flutter plugin, which handles
// all the native Android VPN stuff automatically.
// 3. It listens to the native tunnel state and updates the UI accordingly.
//
// You never need to edit this file.
// Server settings → app_config.dart
// Server communication → wg_api_service.dart
// =============================================================================
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:wireguard_flutter/wireguard_flutter.dart';
import 'package:paladinvpn/logic/app_config.dart';
import 'package:paladinvpn/logic/hivemind_service.dart';
// ── VPN States ────────────────────────────────────────────────────────────────
// These are the possible states the tunnel can be in.
// The UI uses these to decide what to display (spinner, shield icon, etc.)
enum VpnStatus {
disconnected, // No tunnel active — idle state
connecting, // Fetching config from server + starting tunnel
connected, // Tunnel is up and routing traffic
disconnecting, // Tearing down the tunnel
error, // Something went wrong (server unreachable, etc.)
}
// ── VpnConnection ─────────────────────────────────────────────────────────────
// This is a "ChangeNotifier" — it works like a Python observable/event emitter.
// Whenever the state changes, it automatically notifies all widgets that are
// listening, and they re-render themselves. You never call setState() manually.
class VpnConnection extends ChangeNotifier {
// Current tunnel state (starts disconnected)
VpnStatus _status = VpnStatus.disconnected;
VpnStatus get status => _status;
// Short message shown to the user under the connect button
String _statusMessage = 'Tap to connect';
String get statusMessage => _statusMessage;
// Optional error message shown if something goes wrong
String? _errorMessage;
String? get errorMessage => _errorMessage;
// The internal WireGuard IP assigned to this device (e.g. 10.8.0.2)
// Populated after a successful config fetch and used by SessionTimer.
String? internalIp;
// The wireguard_flutter plugin instance — this is what actually talks to Android
final _wireguard = WireGuardFlutter.instance;
// Subscription to the native tunnel state stream
StreamSubscription? _stageSub;
// ── Initialization ────────────────────────────────────────────────────────
// Called automatically when the app starts (via Provider in main.dart)
VpnConnection() {
_init();
}
Future<void> _init() async {
// WireGuard doesn't work in a web browser — skip on web
if (kIsWeb) return;
try {
// Register our tunnel interface with Android.
// 'Paladin0' is just the name of the network interface — like "eth0"
await _wireguard.initialize(interfaceName: 'Paladin0');
} catch (e) {
debugPrint('WireGuard init error (expected on emulator): $e');
}
// Start listening to native tunnel state changes from Android.
// When Android reports the tunnel went up/down, _mapStage() is called.
try {
_stageSub = _wireguard.vpnStageSnapshot.listen(_mapStage);
} catch (_) {}
}
// ── Native State Mapping ──────────────────────────────────────────────────
// Android sends back low-level states like "authenticating", "preparing", etc.
// This function translates those into our simpler 5-state enum above.
void _mapStage(VpnStage stage) {
switch (stage) {
case VpnStage.connected:
_setStatus(VpnStatus.connected, 'Secured');
case VpnStage.disconnected:
_setStatus(VpnStatus.disconnected, 'Tap to connect');
case VpnStage.connecting:
case VpnStage.waitingConnection:
case VpnStage.authenticating:
case VpnStage.preparing:
case VpnStage.reconnect:
_setStatus(VpnStatus.connecting, 'Establishing tunnel…');
case VpnStage.disconnecting:
case VpnStage.exiting:
_setStatus(VpnStatus.disconnecting, 'Tearing down…');
default:
_setStatus(VpnStatus.disconnected, 'Tap to connect');
}
}
// ── Connect ───────────────────────────────────────────────────────────────
// This is the main function — called after the user watches the rewarded ad.
//
// Step 1: Call the WG-Easy API to get the .conf for this device
// Step 2: Pass that .conf to the WireGuard plugin to start the tunnel
//
// Returns true if the connection was successful, false if it failed.
// The connect button uses this to decide whether to start the timer.
Future<bool> connect() async {
// Don't do anything if we're already connected or currently connecting
if (_status == VpnStatus.connected || _status == VpnStatus.connecting) return false;
_setStatus(VpnStatus.connecting, 'Fetching config…');
_errorMessage = null;
// ── Web / emulator fallback ────────────────────────────────────────────
if (kIsWeb) {
await Future.delayed(const Duration(seconds: 1));
_setStatus(VpnStatus.connected, 'Secured (dev mode)');
return true;
}
// ── Step 2: Fetch config from Hivemind ──
String configText;
try {
configText = await HivemindService.fetchConfigWithPolling();
// Parse the internal IP (e.g. "Address = 10.8.0.2/24" or "/32")
final regex = RegExp(r'Address\s*=\s*([0-9\.]+)/');
final match = regex.firstMatch(configText);
if (match != null) {
internalIp = match.group(1);
}
} catch (e) {
debugPrint('Hivemind API error: $e');
// Set the exact error as the status message so the user can see it on screen
String errorMsg = e.toString().replaceAll('Exception: ', '');
if (errorMsg.length > 30) {
errorMsg = '${errorMsg.substring(0, 30)}...'; // keep it somewhat short for the UI
}
_errorMessage = e.toString();
_setStatus(VpnStatus.error, errorMsg);
return false;
}
// ── Step 2: Start the WireGuard tunnel with the fetched config ─────────
try {
// Re-initialize to ensure VPN permission is granted before starting
await _wireguard.initialize(interfaceName: 'Paladin0');
await _wireguard.startVpn(
serverAddress: AppConfig.serverEndpoint,
wgQuickConfig: configText,
providerBundleIdentifier: 'com.paladinvpn.app.WireGuardProvider',
);
_setStatus(VpnStatus.connected, 'Secured');
return true;
} catch (e) {
debugPrint('WireGuard tunnel error: $e');
_errorMessage = 'Tunnel failed to start.\nTry reconnecting.';
_setStatus(VpnStatus.error, 'Tunnel error');
return false;
}
}
// ── Disconnect ────────────────────────────────────────────────────────────
// Tears down the active tunnel and returns Android to normal networking.
Future<void> disconnect() async {
if (_status == VpnStatus.disconnected) return;
_setStatus(VpnStatus.disconnecting, 'Tearing down…');
if (kIsWeb) {
await Future.delayed(const Duration(milliseconds: 500));
_setStatus(VpnStatus.disconnected, 'Tap to connect');
return;
}
try {
await _wireguard.stopVpn();
} catch (e) {
debugPrint('WireGuard stop error: $e');
}
// Clean up the peer from the server so we don't leave dead connections lying around
await HivemindService.disconnectAndCleanup();
_setStatus(VpnStatus.disconnected, 'Tap to connect');
}
// ── Internal state updater ────────────────────────────────────────────────
// Updates state and triggers a UI re-render (like calling setState() in Python/Qt)
void _setStatus(VpnStatus s, String msg) {
_status = s;
_statusMessage = msg;
notifyListeners(); // tells all listening widgets to rebuild
}
@override
void dispose() {
_stageSub?.cancel();
super.dispose();
}
}