Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f28379f73f | ||
|
|
8260c7cc0a | ||
|
|
62e968f133 | ||
|
|
571c07bca8 | ||
|
|
73d76c04f2 | ||
|
|
e751a9d6e4 |
No files matched your search
+62
-31
@@ -5,17 +5,52 @@ Versions follow [semver](https://semver.org/): MAJOR.MINOR.PATCH
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- **The background server check while disconnected.** The app no longer contacts
|
||||||
|
the server every 30 seconds while you are not connected to it. Nothing about
|
||||||
|
the connected session changes.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **The status bar no longer guesses whether the server is up.** It reads
|
||||||
|
"Ready" while disconnected and shows "Online" once a session has synced, so a
|
||||||
|
green dot now means a session that is actually working rather than a
|
||||||
|
reachability check made beforehand.
|
||||||
|
|
||||||
|
## [3.3.8] — 2026-10-17
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Reconnecting always starts a fresh session.** In SOCKS5 mode, the
|
||||||
|
**Reconnect** button used to re-attach to the session you already had. It now
|
||||||
|
ends that session and starts a new one, exactly as the connect button does —
|
||||||
|
one way to begin a session instead of two, and you get a full window back
|
||||||
|
rather than the remainder of a broken one.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **SOCKS5 "Reconnect" works again.** In 3.3.7 it ended your session and then
|
||||||
|
asked for the session it had just ended, failing with "Session not activated".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [3.3.7] — 2026-10-17
|
## [3.3.7] — 2026-10-17
|
||||||
|
|
||||||
|
> **Known issue — fixed in 3.3.8.** In SOCKS5 mode, the **Reconnect**
|
||||||
|
> button in Settings ends your session instead of re-attaching to it, and then
|
||||||
|
> fails with "Session not activated". It only appears when a proxy session has
|
||||||
|
> been orphaned, and you lose the rest of that session rather than anything
|
||||||
|
> worse. Workaround: connect again from the main screen. The "SOCKS5 Reconnect"
|
||||||
|
> entry below describes the intended behaviour, which this build does not have.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **A warning before a session ends.** The VPN service schedules it off the same
|
- **A warning before a session ends.** The VPN service schedules it off the same
|
||||||
deadline it already enforces, so it still arrives when the app is closed, and
|
deadline it already enforces, so it arrives even with the app closed, and a
|
||||||
a support top-up moves it with the deadline. It does not promise an exact
|
support top-up moves it with the deadline. It does not promise an exact number
|
||||||
number of minutes: on a sleeping phone Android may hold it back, and the app
|
of minutes: on a deeply sleeping phone Android can hold it back past the end
|
||||||
would rather say "soon" than say "5 minutes" with one left.
|
of the session, in which case it is skipped rather than shown late.
|
||||||
- **"Disconnected — not protected" notification** when a session ends without
|
- **A "disconnected — not protected" notification** when a session ends without
|
||||||
you asking: the 2-hour expiry, the data limit, the server ending the
|
you asking: the 2-hour expiry, the data limit, or the server ending the
|
||||||
session, or the tunnel stopping unexpectedly.
|
session.
|
||||||
- **SOCKS5 mode now asks for the notification permission.** It never did,
|
- **SOCKS5 mode now asks for the notification permission.** It never did,
|
||||||
because it never raises the VPN consent dialog, so on Android 13+ these
|
because it never raises the VPN consent dialog, so on Android 13+ these
|
||||||
alerts would have been dropped before they were shown.
|
alerts would have been dropped before they were shown.
|
||||||
@@ -28,36 +63,32 @@ Versions follow [semver](https://semver.org/): MAJOR.MINOR.PATCH
|
|||||||
to disconnect itself after about two minutes of failed status polls, putting
|
to disconnect itself after about two minutes of failed status polls, putting
|
||||||
you back on your real IP; it now keeps the tunnel up and shows
|
you back on your real IP; it now keeps the tunnel up and shows
|
||||||
"Can't reach the server — still connected". Expiry is still enforced by the
|
"Can't reach the server — still connected". Expiry is still enforced by the
|
||||||
VPN service's own deadline and by the server.
|
VPN service's own deadline, which keeps running regardless.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- **Rewarded ads no longer break connecting.** With ads enabled, the ad's
|
- **The countdown no longer over-reports after the phone sleeps**, and a clock
|
||||||
server-side-verification nonce and the nonce the connect flow waited for were
|
moved backwards can no longer stretch a session.
|
||||||
minted separately, so the client rejected the very session the ad created and
|
- **A session that expired while the phone was asleep is recognised on waking**,
|
||||||
failed with "Session not activated". The ad's nonce is now passed through to
|
by wall clock rather than by time the device spent awake.
|
||||||
the connect call, the fake bypass callback is skipped when a real ad was
|
|
||||||
watched, and the client polls longer (~36 s) for Google's callback.
|
|
||||||
- **SOCKS5 "Reconnect" re-attaches to the current session** instead of
|
|
||||||
requesting a new one, so repairing a lost proxy no longer needs (or grants)
|
|
||||||
a fresh session.
|
|
||||||
- **The end-of-session warning now actually arrives when the app is closed.**
|
|
||||||
It was scheduled only on an inexact alarm, and a sleeping phone could hold
|
|
||||||
that alarm back past the end of the session it was warning about — so in the
|
|
||||||
one case the warning exists for, it never came. It now also runs off the VPN
|
|
||||||
service's own timer, the same one that already ends the session on time.
|
|
||||||
- **Disconnecting from Android's VPN settings no longer leaves a countdown in
|
- **Disconnecting from Android's VPN settings no longer leaves a countdown in
|
||||||
the notification shade.** It used to sit there frozen, showing time remaining
|
the notification shade.** It used to sit there frozen, showing time remaining
|
||||||
on a tunnel that was already gone.
|
on a tunnel that was already gone.
|
||||||
- **The VPN reconnects itself after the tunnel core stops.** Recovery never
|
- **The VPN recovers after the tunnel core stops.** Recovery never worked:
|
||||||
worked: every attempt failed on the same internal error, and each one left
|
every attempt failed on the same internal error, and each one left another
|
||||||
another copy of the core running in the background. One restart now brings it
|
copy of the core running in the background. It now usually comes back on the
|
||||||
back without dropping the connection.
|
first retry, without dropping the connection.
|
||||||
- **Disconnecting now releases your session on the server** instead of leaving
|
- **Disconnecting with the app's own button now releases your session on the
|
||||||
it open until it expires on its own.
|
server** instead of leaving it open until it expires.
|
||||||
- **A core that fails to start now says why.** Its output was being thrown
|
- **SOCKS5 "Reconnect" re-attaches to the current session** instead of
|
||||||
away, so the only trace was an exit code.
|
requesting a new one — see the known issue above; this does not work in 3.3.7.
|
||||||
|
- **A core that fails to start now logs why.** Its output was being discarded,
|
||||||
|
so the only trace was an exit code.
|
||||||
|
|
||||||
---
|
### Groundwork
|
||||||
|
- **Rewarded-ad plumbing.** Ads are switched off in this build, so none of this
|
||||||
|
is reachable yet: the ad's verification nonce is now carried through to the
|
||||||
|
connect call rather than being minted twice, and the client waits longer
|
||||||
|
(~36 s) for Google's callback. Reviewed, not yet exercised end to end.
|
||||||
|
|
||||||
## [3.3.6] — 2026-10-08
|
## [3.3.6] — 2026-10-08
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -1,6 +1,6 @@
|
|||||||
# Privacy Policy
|
# Privacy Policy
|
||||||
|
|
||||||
**Last updated:** September 2026
|
**Last updated:** October 2026
|
||||||
|
|
||||||
ReVoltVPN is a free VPN service built and operated by ReVolt Team.
|
ReVoltVPN is a free VPN service built and operated by ReVolt Team.
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ When you first open the app, we show a brief summary of this policy before you c
|
|||||||
|
|
||||||
We collect the minimum required to operate the service:
|
We collect the minimum required to operate the service:
|
||||||
|
|
||||||
- A randomly generated device ID created on your phone. This is not linked to your identity in any way.
|
- A randomly generated device ID created on your phone. It is stored on your device and reused between sessions, and it is not linked to your identity in any way.
|
||||||
- Your connection duration and data usage for the current session, linked to your device ID. This is used solely to enforce the per-session quota (see below).
|
- Your connection duration and data usage for the current session, linked to your device ID. This is used solely to enforce the per-session quota (see below).
|
||||||
|
|
||||||
## What our server sees
|
## What our server sees
|
||||||
@@ -27,14 +27,14 @@ Support ads (the "Support us" button) extend an active session by 30 minutes and
|
|||||||
|
|
||||||
## Operational logs
|
## Operational logs
|
||||||
|
|
||||||
Server logs are written to the systemd journal (`journalctl -u hivemind`). These logs include truncated device IDs (first 8 characters only), session start/stop events, and error messages. Logs stay on the server and are accessible only to the server operator via local console; retention follows the server's systemd journal settings. We also maintain an aggregate total of all data ever transferred (in GB) for capacity planning — this counter is not tied to any individual device.
|
Server logs are written to the system journal. These logs include truncated device IDs (first 8 characters only), session start/stop events, and error messages. Logs stay on the server and are accessible only to the server operator via local console; retention follows the server's journal settings. We also maintain an aggregate total of all data ever transferred (in GB) for capacity planning — this counter is not tied to any individual device.
|
||||||
|
|
||||||
## What we do not collect
|
## What we do not collect
|
||||||
|
|
||||||
- We do not collect your name, email, or any personal information.
|
- We do not collect your name, email, or any personal information.
|
||||||
- We do not log your internet traffic, browsing history, DNS queries, or destination IPs.
|
- We do not log your internet traffic, browsing history, DNS queries, or destination IPs.
|
||||||
- We do not inspect or store the content of your traffic.
|
- We do not inspect or store the content of your traffic.
|
||||||
- We do not sell or share any data with third parties.
|
- We do not sell your data. We do not share it with anyone, apart from what Google's ad SDK collects for ad delivery — see “Ads & Consent” below.
|
||||||
|
|
||||||
## Ads & Consent
|
## Ads & Consent
|
||||||
|
|
||||||
|
|||||||
@@ -49,15 +49,14 @@ class StatusBar extends StatelessWidget {
|
|||||||
builder: (context, vpn, timer, _) {
|
builder: (context, vpn, timer, _) {
|
||||||
final isConnected = vpn.status == VpnStatus.connected;
|
final isConnected = vpn.status == VpnStatus.connected;
|
||||||
final isConnecting = vpn.status == VpnStatus.connecting;
|
final isConnecting = vpn.status == VpnStatus.connecting;
|
||||||
final serverHealthy =
|
// Rule 60: reachability is known only from a session sync that
|
||||||
vpn.serverReachable || (isConnected && timer.hasSyncedOnce);
|
// succeeded, never from a probe. While disconnected the app knows
|
||||||
final sessionOrTunnelHealthy = timer.hasSyncedOnce || vpn.serverReachable;
|
// nothing about the server, so it claims nothing either way.
|
||||||
|
final serverHealthy = isConnected && timer.hasSyncedOnce;
|
||||||
|
|
||||||
final statusLabel = isConnected
|
final statusLabel = isConnected
|
||||||
? (sessionOrTunnelHealthy ? 'Online' : 'Syncing…')
|
? (timer.hasSyncedOnce ? 'Online' : 'Syncing…')
|
||||||
: (isConnecting
|
: (isConnecting ? 'Connecting…' : 'Ready');
|
||||||
? 'Connecting…'
|
|
||||||
: (vpn.serverReachable ? 'Ready' : 'Offline'));
|
|
||||||
|
|
||||||
final detail = _detail(vpn, timer);
|
final detail = _detail(vpn, timer);
|
||||||
|
|
||||||
|
|||||||
@@ -146,14 +146,13 @@ class HivemindService {
|
|||||||
_expectedNonce = null;
|
_expectedNonce = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetches this device's VLESS URL.
|
// Fetches this device's VLESS URL. Every session starts the same way, so
|
||||||
// adNonce — wait for the session that ad created (SSV can lag, so poll longer)
|
// there is always a nonce to bind it to: either the one the ad minted, or one
|
||||||
// resume — re-attach to the session already held; any nonce accepted
|
// we mint here before firing the bypass (rule 87). The re-attach path that
|
||||||
// neither — ads off: mint a nonce and fire the bypass (rule 87)
|
// used to accept any nonce is gone - see rule 4.
|
||||||
static Future<String> fetchConfigDirectly({
|
static Future<String> fetchConfigDirectly({
|
||||||
void Function(int attempt, int total)? onAttempt,
|
void Function(int attempt, int total)? onAttempt,
|
||||||
String? adNonce,
|
String? adNonce,
|
||||||
bool resume = false,
|
|
||||||
}) async {
|
}) async {
|
||||||
// Capture the generation before the first await. Otherwise a disconnect
|
// Capture the generation before the first await. Otherwise a disconnect
|
||||||
// that lands while secure storage is resolving the device ID can be lost
|
// that lands while secure storage is resolving the device ID can be lost
|
||||||
@@ -162,11 +161,11 @@ class HivemindService {
|
|||||||
final deviceId = await CryptoService.getDeviceId();
|
final deviceId = await CryptoService.getDeviceId();
|
||||||
_throwIfCancelled(callId);
|
_throwIfCancelled(callId);
|
||||||
|
|
||||||
final nonce = resume ? null : (adNonce ?? newNonce());
|
final nonce = adNonce ?? newNonce();
|
||||||
_expectedNonce = nonce;
|
_expectedNonce = nonce;
|
||||||
|
|
||||||
if (!resume && adNonce == null) {
|
if (adNonce == null) {
|
||||||
await _runConfiguredBypass(deviceId, nonce!);
|
await _runConfiguredBypass(deviceId, nonce);
|
||||||
_throwIfCancelled(callId);
|
_throwIfCancelled(callId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,18 +197,6 @@ class HivemindService {
|
|||||||
throw Exception('Session not activated. Server callback may have timed out.');
|
throw Exception('Session not activated. Server callback may have timed out.');
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<bool> checkHealth() async {
|
|
||||||
try {
|
|
||||||
final response = await controlGet(
|
|
||||||
_publicUrl('/health'),
|
|
||||||
timeout: const Duration(seconds: 3),
|
|
||||||
);
|
|
||||||
return response.statusCode == 200;
|
|
||||||
} catch (_) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static String newNonce() {
|
static String newNonce() {
|
||||||
final random = Random.secure();
|
final random = Random.secure();
|
||||||
final high = random.nextInt(0x7FFFFFFF);
|
final high = random.nextInt(0x7FFFFFFF);
|
||||||
@@ -229,10 +216,9 @@ class HivemindService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// nonce == null accepts whatever nonce the server holds (resume path).
|
|
||||||
static Future<_HivemindSessionConfig?> _fetchActiveSession(
|
static Future<_HivemindSessionConfig?> _fetchActiveSession(
|
||||||
String deviceId,
|
String deviceId,
|
||||||
String? nonce,
|
String nonce,
|
||||||
) async {
|
) async {
|
||||||
final statusUri = _publicUrl('/session/status').replace(
|
final statusUri = _publicUrl('/session/status').replace(
|
||||||
queryParameters: {'device_id': deviceId},
|
queryParameters: {'device_id': deviceId},
|
||||||
@@ -245,9 +231,7 @@ class HivemindService {
|
|||||||
|
|
||||||
final serverNonce = decoded['nonce'];
|
final serverNonce = decoded['nonce'];
|
||||||
if (_expectedNonce != nonce) return null;
|
if (_expectedNonce != nonce) return null;
|
||||||
if (nonce != null && (serverNonce is! String || serverNonce != nonce)) {
|
if (serverNonce is! String || serverNonce != nonce) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (decoded['active'] != true || decoded['vless_uuid'] == null) {
|
if (decoded['active'] != true || decoded['vless_uuid'] == null) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -158,9 +158,7 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// resetSupportClaim is false when re-attaching to a session already held:
|
Future<void> start() async {
|
||||||
// the top-up is once per server session and this flag is what enforces it.
|
|
||||||
Future<void> start({bool resetSupportClaim = true}) async {
|
|
||||||
_sessionEpoch++;
|
_sessionEpoch++;
|
||||||
_remainingSeconds = 0;
|
_remainingSeconds = 0;
|
||||||
_remainingAtLastSync = 0;
|
_remainingAtLastSync = 0;
|
||||||
@@ -178,12 +176,11 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
_lastEndMessage = null;
|
_lastEndMessage = null;
|
||||||
NotificationService.reset();
|
NotificationService.reset();
|
||||||
|
|
||||||
if (resetSupportClaim) {
|
// Every session is new, so the once-per-session top-up resets with it.
|
||||||
_supportStateEpoch++;
|
_supportStateEpoch++;
|
||||||
_supportRewardClaimed = false;
|
_supportRewardClaimed = false;
|
||||||
_supportRewardStateLoaded = true;
|
_supportRewardStateLoaded = true;
|
||||||
unawaited(_persistSupportRewardState(false));
|
unawaited(_persistSupportRewardState(false));
|
||||||
}
|
|
||||||
|
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
_timer = Timer.periodic(const Duration(seconds: 1), _tick);
|
_timer = Timer.periodic(const Duration(seconds: 1), _tick);
|
||||||
|
|||||||
@@ -38,9 +38,6 @@ class VpnConnection extends ChangeNotifier {
|
|||||||
bool _adoptedRunningRuntime = false;
|
bool _adoptedRunningRuntime = false;
|
||||||
bool get adoptedRunningRuntime => _adoptedRunningRuntime;
|
bool get adoptedRunningRuntime => _adoptedRunningRuntime;
|
||||||
|
|
||||||
bool _serverReachable = false;
|
|
||||||
bool get serverReachable => _serverReachable;
|
|
||||||
|
|
||||||
ConnectionMode _activeMode = ConnectionMode.tun;
|
ConnectionMode _activeMode = ConnectionMode.tun;
|
||||||
ConnectionMode get activeMode => _activeMode;
|
ConnectionMode get activeMode => _activeMode;
|
||||||
|
|
||||||
@@ -73,7 +70,6 @@ class VpnConnection extends ChangeNotifier {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Timer? _healthTimer;
|
|
||||||
StreamSubscription<NetworkSnapshot>? _networkSubscription;
|
StreamSubscription<NetworkSnapshot>? _networkSubscription;
|
||||||
|
|
||||||
SecureSocksSession? _lastSecureSocks;
|
SecureSocksSession? _lastSecureSocks;
|
||||||
@@ -142,11 +138,6 @@ class VpnConnection extends ChangeNotifier {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
unawaited(_checkHealth());
|
|
||||||
_healthTimer = Timer.periodic(const Duration(seconds: 30), (_) {
|
|
||||||
unawaited(_checkHealth());
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final coreVersion = await _vless.getCoreVersion();
|
final coreVersion = await _vless.getCoreVersion();
|
||||||
if (!_disposed) {
|
if (!_disposed) {
|
||||||
@@ -200,7 +191,7 @@ class VpnConnection extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> connect({String? adNonce, bool resume = false}) async {
|
Future<bool> connect({String? adNonce}) async {
|
||||||
if (_disposed ||
|
if (_disposed ||
|
||||||
_userDisconnecting ||
|
_userDisconnecting ||
|
||||||
_status == VpnStatus.connected ||
|
_status == VpnStatus.connected ||
|
||||||
@@ -266,7 +257,6 @@ class VpnConnection extends ChangeNotifier {
|
|||||||
try {
|
try {
|
||||||
realUrl = await HivemindService.fetchConfigDirectly(
|
realUrl = await HivemindService.fetchConfigDirectly(
|
||||||
adNonce: adNonce,
|
adNonce: adNonce,
|
||||||
resume: resume,
|
|
||||||
onAttempt: (attempt, total) {
|
onAttempt: (attempt, total) {
|
||||||
if (!_isCurrentConnect(connectEpoch)) return;
|
if (!_isCurrentConnect(connectEpoch)) return;
|
||||||
_setStatus(
|
_setStatus(
|
||||||
@@ -473,14 +463,6 @@ class VpnConnection extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _checkHealth() async {
|
|
||||||
if (_disposed || _status == VpnStatus.connected) return;
|
|
||||||
final reachable = await HivemindService.checkHealth();
|
|
||||||
if (_disposed || _status == VpnStatus.connected) return;
|
|
||||||
_serverReachable = reachable;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
@@ -488,7 +470,6 @@ class VpnConnection extends ChangeNotifier {
|
|||||||
_suppressNativeConnect = true;
|
_suppressNativeConnect = true;
|
||||||
_userDisconnecting = true;
|
_userDisconnecting = true;
|
||||||
HivemindService.cancel();
|
HivemindService.cancel();
|
||||||
_healthTimer?.cancel();
|
|
||||||
_networkSubscription?.cancel();
|
_networkSubscription?.cancel();
|
||||||
// Provider/UI disposal is not a user disconnect command. Keeping teardown
|
// Provider/UI disposal is not a user disconnect command. Keeping teardown
|
||||||
// in disconnect() prevents lifecycle churn from silently dropping the VPN.
|
// in disconnect() prevents lifecycle churn from silently dropping the VPN.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:revoltvpn/logic/ad_manager.dart';
|
||||||
import 'package:revoltvpn/logic/app_colors.dart';
|
import 'package:revoltvpn/logic/app_colors.dart';
|
||||||
import 'package:revoltvpn/logic/connection_settings.dart';
|
import 'package:revoltvpn/logic/connection_settings.dart';
|
||||||
import 'package:revoltvpn/logic/local_socks_tester.dart';
|
import 'package:revoltvpn/logic/local_socks_tester.dart';
|
||||||
@@ -67,20 +68,27 @@ class _ConnectionModeTileState extends State<ConnectionModeTile> {
|
|||||||
widget.onChanged?.call(next);
|
widget.onChanged?.call(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recovers an orphaned proxy: tear the runtime down and re-attach to the
|
// Recovers an orphaned proxy by ending the session and starting a new one,
|
||||||
// session already held. No ad, no new session.
|
// exactly as the connect button does. Re-attaching to the session already
|
||||||
|
// held was the one place a session began without an ad and without a nonce
|
||||||
|
// to bind it to; a fresh one is the same path everywhere and hands back a
|
||||||
|
// full window rather than the remainder of a broken one.
|
||||||
Future<void> _reconnect() async {
|
Future<void> _reconnect() async {
|
||||||
if (_reconnecting) return;
|
if (_reconnecting) return;
|
||||||
final vpn = context.read<VpnConnection>();
|
final vpn = context.read<VpnConnection>();
|
||||||
final timer = context.read<SessionTimer>();
|
final timer = context.read<SessionTimer>();
|
||||||
|
final ad = context.read<AdManager>();
|
||||||
|
|
||||||
setState(() => _reconnecting = true);
|
setState(() => _reconnecting = true);
|
||||||
try {
|
try {
|
||||||
await timer.disconnect();
|
await timer.disconnect();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (await vpn.connect(resume: true)) {
|
final adNonce = await ad.showAd('main');
|
||||||
|
if (adNonce == null && AdManager.adsEnabled) return;
|
||||||
|
if (!mounted) return;
|
||||||
|
if (await vpn.connect(adNonce: adNonce)) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
await timer.start(resetSupportClaim: false);
|
await timer.start();
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
|||||||
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
|
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
|
||||||
# Read more about iOS versioning at
|
# Read more about iOS versioning at
|
||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
version: 3.3.7+33
|
version: 3.3.8+34
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=3.0.0 <4.0.0"
|
sdk: ">=3.0.0 <4.0.0"
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ void main() {
|
|||||||
// /session/stop takes a device_id as its only proof of ownership, so it
|
// /session/stop takes a device_id as its only proof of ownership, so it
|
||||||
// must never be reachable from an error path. disconnect() is called only
|
// must never be reachable from an error path. disconnect() is called only
|
||||||
// by the connect button; _doDisconnect is every other ending.
|
// by the connect button; _doDisconnect is every other ending.
|
||||||
final disconnectEntry = timer.indexOf('Future<void> disconnect(');
|
final disconnectEntry = timer.indexOf('Future<void> disconnect({');
|
||||||
final doDisconnect = timer.indexOf('Future<void> _doDisconnect(');
|
final doDisconnect = timer.indexOf('Future<void> _doDisconnect(');
|
||||||
final release = timer.indexOf('HivemindService.releaseSession()');
|
final release = timer.indexOf('HivemindService.releaseSession()');
|
||||||
expect(release, greaterThan(disconnectEntry));
|
expect(release, greaterThan(disconnectEntry));
|
||||||
@@ -131,4 +131,57 @@ void main() {
|
|||||||
reason: 'exactly one call site',
|
reason: 'exactly one call site',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('every session starts the same way - no re-attach path', () {
|
||||||
|
final mode =
|
||||||
|
File('lib/screens/settings/in_settings/connection_mode.dart')
|
||||||
|
.readAsStringSync();
|
||||||
|
final hivemind = File('lib/logic/hivemind_service.dart').readAsStringSync();
|
||||||
|
final vpn = File('lib/logic/vpn_connection.dart').readAsStringSync();
|
||||||
|
final timer = File('lib/logic/session_timer.dart').readAsStringSync();
|
||||||
|
|
||||||
|
// SOCKS5 Reconnect used to re-attach to the session already held. It was
|
||||||
|
// the only place a session began without an ad and without a nonce binding
|
||||||
|
// it, and it needed three parameters and a carve-out in rule 4 to support.
|
||||||
|
// Reconnect now ends the session and starts a fresh one, like the button.
|
||||||
|
expect(mode, contains("ad.showAd('main')"));
|
||||||
|
expect(mode, contains('vpn.connect(adNonce: adNonce)'));
|
||||||
|
expect(mode, isNot(contains('resume: true')));
|
||||||
|
expect(mode, isNot(contains('release: false')));
|
||||||
|
expect(mode, isNot(contains('resetSupportClaim')));
|
||||||
|
|
||||||
|
// The special cases that only existed for it are gone.
|
||||||
|
expect(hivemind, isNot(contains('bool resume')));
|
||||||
|
expect(vpn, isNot(contains('bool resume')));
|
||||||
|
expect(timer, isNot(contains('bool release')));
|
||||||
|
expect(timer, isNot(contains('resetSupportClaim')));
|
||||||
|
|
||||||
|
// So the nonce is now unconditional: no path accepts whatever the server
|
||||||
|
// happens to hold.
|
||||||
|
expect(
|
||||||
|
hivemind,
|
||||||
|
contains('if (serverNonce is! String || serverNonce != nonce) return null;'),
|
||||||
|
reason: 'the nonce check must not be skippable',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nothing probes the control plane while disconnected - rule 60', () {
|
||||||
|
final vpn = File('lib/logic/vpn_connection.dart').readAsStringSync();
|
||||||
|
final hivemind = File('lib/logic/hivemind_service.dart').readAsStringSync();
|
||||||
|
final bar = File('lib/components/status_bar.dart').readAsStringSync();
|
||||||
|
|
||||||
|
// The runtime excludes the app from its own tunnel
|
||||||
|
// (XrayVPNService.kt addDisallowedApplication), so a periodic /health probe
|
||||||
|
// was a cleartext-SNI metronome to the API host - which resolves to the
|
||||||
|
// tunnel IP. It fed step 1 of the kill chain in KNOWN_SECURITY_ISSUES #3,
|
||||||
|
// in exchange for a green dot before the user had connected to anything.
|
||||||
|
expect(vpn, isNot(contains('_healthTimer')));
|
||||||
|
expect(vpn, isNot(contains('_checkHealth')));
|
||||||
|
expect(hivemind, isNot(contains('checkHealth')));
|
||||||
|
expect(hivemind, isNot(contains("'/health'")));
|
||||||
|
|
||||||
|
// Reachability is passive now: a session sync that succeeded, nothing else.
|
||||||
|
expect(bar, isNot(contains('serverReachable')));
|
||||||
|
expect(bar, contains('isConnected && timer.hasSyncedOnce'));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user