Skip to content

Commit d8fd2a9

Browse files
committed
Write the documents a VPN-class app carries, and stop the README lying
The README's status table said "not yet usable as a VPN: there is no VpnService and no TUN layer, so nothing routes device traffic". That stopped being true four runs ago. A status section is the first thing anyone reads and the last thing anyone updates, and this one had drifted far enough to describe a different project. It now says what is true, including the parts that are not flattering: nothing has ever run on physical hardware, the QUIC carrier does no certificate verification, no release has been published and no signing key exists. The build section names what a first build actually needs — Go and perl for aws-lc, a network step because the QUIC stack is fetched rather than vendored — and how to produce the corresponding source a GPL binary that links it must carry. privacy.md is written the way an app of this kind should write one: the absences are the part worth reading, so they are listed individually rather than summed up as "we respect your privacy". Nothing is collected, and the claim is made checkable — the dependency list contains no analytics library and a reader can verify that in one pass, which is worth more than a promise. What the app stores is named file by file, including that a shared key is a credential kept in plain text in private storage, which is the same protection the platform gives every app and weaker than it sounds on a rooted device. store-policy.md records what a VPN listing requires against what this app already does, and separates that from what is still someone's decision: the package identifier, which D-02b makes irreversible at first publication and which the first signed build has now made due; a signing key, which is the app's identity for life; export compliance; and the distribution channel. Two things it says the listing must not: this app does not claim to resist censorship — upstream does not claim it either, and the words appear zero times in its own security document — and it is not a free VPN, because it carries no service and does nothing without a Portal the user brings. Gates green.
1 parent 97f9666 commit d8fd2a9

3 files changed

Lines changed: 262 additions & 25 deletions

File tree

‎README.md‎

Lines changed: 76 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,34 @@ engine is hand-written in Kotlin rather than wrapping an existing core.
88

99
## Status
1010

11-
Pre-release, and **not yet usable as a VPN**: there is no `VpnService` and no TUN
12-
layer, so nothing routes device traffic. What exists:
11+
Pre-release, and **not published anywhere**. The protocol is complete: L1, L2 and
12+
L3 all work against a live upstream Portal, and a node URL with no parameters —
13+
which is a QUIC node, because the specification's default for both directions is
14+
`udp` — connects and carries traffic.
1315

1416
| | |
1517
|---|---|
16-
| Protocol, L1 | Authentication, flow setup, the dedicated TLS lane, the seven setup outcomes. Verified against a live upstream Portal, not only against fixtures |
17-
| Transport | TLS dialing with all three certificate modes — skipped, `sni`, `pin` — each tested against a live Portal |
18-
| Nodes | Stored on device, imported from a `nowhere://` link, probed for reachability |
19-
| Subscription | Fetching and parsing, including the quota semantics upstream actually has |
20-
| UI | All eight screens, both themes, three locales |
21-
| Not yet | TUN / lwIP, `VpnService`, Mux (L2), QUIC (L3) |
22-
23-
Delivery is layered: L1 TLS/TCP, then L2 Mux, then L3 QUIC. Each layer ships to
24-
internal testing before the next one starts.
18+
| Protocol, L1 | Authentication, flow setup, the dedicated TLS lane, the seven setup outcomes |
19+
| Protocol, L2 | TLS Mux. Sixteen concurrent flows measured over four connections, at upstream's stated shard density |
20+
| Protocol, L3 | QUIC: authentication, TCP flows over reliable streams, UDP over DATAGRAM with fragmentation, split flows across two carriers |
21+
| Carriers | All four combinations — `tcp/tcp`, `udp/udp`, `udp/tcp`, `tcp/udp` — compared case by case against the reference implementation, which agrees on every one |
22+
| Tunnel | `VpnService` + lwIP, TCP with back-pressure, UDP, DNS interception with fake-IP, live throughput |
23+
| Routing | A rule matcher, an import path, a bundled network-structural rule set, a direct path, a reject path |
24+
| Per-app | A real installed list, a persisted selection, and this client structurally outside its own tunnel |
25+
| Nodes | Stored, imported from a `nowhere://` link, probed for reachability |
26+
| Subscription | Fetching and parsing, with upstream's actual quota semantics |
27+
| UI | Nine screens, both themes, three locales |
28+
29+
**What is not done**, stated as plainly as the rest:
30+
31+
- **Nothing has ever run on physical hardware.** Every device result is from an
32+
emulator. Private DNS over DoT, IPv6-only and NAT64 networks, a real Wi-Fi to
33+
cellular change, Doze and a path MTU below 1500 are all things an emulator
34+
cannot represent, and all things a VPN meets on the first real device.
35+
- **The QUIC carrier does no certificate verification.** A node carrying `sni`
36+
or `pin` is refused rather than carried without it. The TLS carrier implements
37+
both.
38+
- **No release has been published**, and no signing key exists.
2539

2640
## Protocol baseline
2741

@@ -39,14 +53,22 @@ interop checks need it, and community bug reports need it to be meaningful.
3953
```sh
4054
python3 conformance/scripts/verify-vectors.py # 45 known-answer checks
4155
NOWHERE_CLONE=/path/to/Nowhere conformance/scripts/drift-check.sh
56+
conformance/scripts/oracle-diff.sh # this client against upstream's own
4257
```
4358

59+
`oracle-diff.sh` is the one worth running. It puts the same case list through
60+
this implementation and the reference client, over every carrier combination
61+
both support, and diffs the outcomes. Its only finding to date was a protocol
62+
fact that had grown a second shape the moment a second carrier landed — invisible
63+
to every test that asserted on one carrier's own types, and about to degrade
64+
seven distinct rejection messages to a generic failure on a user's screen.
65+
4466
The suite makes no assumption about where an upstream clone lives; point
4567
`NOWHERE_CLONE` at one.
4668

4769
### Tests that need a live Portal
4870

49-
Sixteen tests dial a real Portal rather than a fake, because the failures that
71+
Many tests dial a real Portal rather than a fake, because the failures that
5072
matter most are the ones no fixture predicts — a rejected authentication frame
5173
is met with **silence** rather than a close, which a fake returning EOF will
5274
never teach you. They skip cleanly when no Portal is running, so an environment
@@ -66,6 +88,8 @@ conformance/scripts/portal-for-tests.sh --stop
6688
| [`docs/brand.md`](docs/brand.md) | The brand hue, which carries no protocol meaning and is derived rather than picked |
6789
| [`docs/i18n.md`](docs/i18n.md) | The three shipping locales, and the rule that machine identifiers stay English while sentences translate |
6890
| [`docs/adr-0001-tls-exporter.md`](docs/adr-0001-tls-exporter.md) | Why the TLS exporter has two sources, and why `minSdk` is 26 |
91+
| [`docs/privacy.md`](docs/privacy.md) | What is stored, what leaves the device, and the longer list of what is not collected |
92+
| [`docs/store-policy.md`](docs/store-policy.md) | What a VPN-class listing requires, what is already satisfied, and what is still someone's decision |
6993

7094
[`docs/architecture.md`](docs/architecture.md) covers the layers, which way they
7195
depend, and the rules that keep them apart.
@@ -79,26 +103,50 @@ claimed, because measuring has already caught defects the eye did not.
79103

80104
```sh
81105
./gradlew ktlintCheck testDebugUnitTest koverVerifyDebug lintDebug \
82-
checkClasspathConsistency assembleDebug
106+
checkClasspathConsistency assembleDebug \
107+
checkNativeLibraries checkNativeBridge checkReleaseArtifact
83108
python3 conformance/scripts/verify-vectors.py
84109
```
85110

86-
The protocol layer carries a 90% line-coverage gate. `checkClasspathConsistency`
87-
fails when a module resolves to different versions on the compile and runtime
88-
classpaths — added after exactly that shipped a crash with every other gate
89-
green. CI runs all of the above on every pull request, and checks upstream for
90-
normative protocol changes daily.
111+
The protocol layer carries a 90% line-coverage gate. Every other gate here was
112+
added after something got through: `checkClasspathConsistency` after a module
113+
resolved to different versions on the compile and runtime classpaths and crashed
114+
a screen with everything else green; `checkNativeLibraries` after linking aws-lc
115+
exported 1,684 of the crypto library's own symbols into a process that already
116+
runs a second BoringSSL; `checkNativeBridge` because the QUIC bridge must have no
117+
way to open a socket that escapes `VpnService.protect()`; `checkReleaseArtifact`
118+
because R8 renames the JNI callbacks C resolves by name, and the failure is not a
119+
crash but a tunnel that comes up and answers nothing.
120+
121+
Each was verified to fail before it was trusted. CI runs all of the above on
122+
every pull request, and checks upstream for normative protocol changes daily.
91123

92124
## Build
93125

94-
Requires JDK 21 and the Android SDK with platform 36. NDK and CMake become
95-
requirements once the native TUN layer is inherited.
126+
Requires JDK 21, the Android SDK with platform 36, the NDK pinned in
127+
`app/build.gradle.kts`, and CMake 3.22.1. The QUIC stack additionally needs Go
128+
and perl on the build host — aws-lc generates assembly with perl and its build
129+
tooling with Go — and it is fetched at pinned commits rather than vendored, so a
130+
first build has a network step. It takes about half a minute per ABI and nothing
131+
thereafter.
96132

97133
```sh
98134
echo "sdk.dir=/path/to/android-sdk" > local.properties
99135
./gradlew assembleDebug
100136
```
101137

138+
A release build works without a keystore and produces an unsigned APK; with one
139+
configured in `local.properties` it is signed. Signing material lives nowhere in
140+
this repository.
141+
142+
```sh
143+
./gradlew assembleRelease
144+
tools/quic/build-deps.sh --bundle-source corresponding-source.tar.gz
145+
```
146+
147+
The second command produces the corresponding source for the statically linked
148+
ngtcp2 and aws-lc. A GPL-3.0 binary that links them must be accompanied by it.
149+
102150
## Security
103151

104152
Somewhere carries user traffic; security defects are the highest-priority class
@@ -117,9 +165,12 @@ Two properties worth stating plainly:
117165

118166
GPL-3.0-only. See [LICENSE](LICENSE).
119167

120-
Bundled fonts are OFL-1.1 and icon path geometry is ISC; see
121-
[THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md).
168+
Bundled fonts are OFL-1.1, icon path geometry is ISC, lwIP is BSD-3-Clause,
169+
ngtcp2 is MIT and aws-lc is Apache-2.0 OR ISC; see
170+
[THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md), which also records what was
171+
deliberately not taken from each.
122172

123-
Copyright (C) 2026 The Somewhere Authors. Portions inherited from
124-
[Anywhere-Android](https://github.com/NodePassProject/Anywhere-Android) (GPL-3.0)
125-
will carry their original attribution when they land.
173+
Copyright (C) 2026 The Somewhere Authors. The lwIP layer and its bridge are
174+
inherited from
175+
[Anywhere-Android](https://github.com/NodePassProject/Anywhere-Android)
176+
(GPL-3.0) and carry their original attribution.

‎docs/privacy.md‎

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# Privacy
2+
3+
> Applies to Somewhere for Android, package `eu.nodepass.somewhere`.
4+
> Last reviewed 2026-08-29, against the source at that date.
5+
6+
## The short version
7+
8+
**This app sends nothing to its authors.** There is no analytics, no crash
9+
reporting and no telemetry of any kind — not disabled by default, not
10+
opt-out: absent. The dependency list in
11+
[`gradle/libs.versions.toml`](../gradle/libs.versions.toml) contains no
12+
analytics or reporting library, and a reader can check that in one pass, which
13+
is the point of saying it this way rather than promising it.
14+
15+
Everything the app stores stays on the device. Everything it sends goes to a
16+
server **you configured**, and nowhere else.
17+
18+
## What is stored on the device
19+
20+
All of it in the app's private storage, which no other app can read, and all of
21+
it removed when the app is uninstalled.
22+
23+
| What | Why | Where |
24+
|---|---|---|
25+
| Nodes, including their shared keys | A node is unusable without its key | `NodeStore` |
26+
| A subscription URL and its title | To refresh the node list | `SubscriptionStore` |
27+
| Routing rules you imported | To decide what goes through the tunnel | `RuleStore` |
28+
| Which applications the tunnel carries | Applied when the tunnel is built | `AppSelectionStore` |
29+
| Settings | Preferences | `RoutingPreferences` |
30+
31+
**A shared key and a subscription URL are credentials.** They are stored in
32+
plain text in private storage, which is the same protection the platform gives
33+
every app's own files. On a rooted device, or one with an unlocked bootloader,
34+
that protection is weaker than it sounds — this is stated because it is true,
35+
not because it is unusual.
36+
37+
## What leaves the device, and to where
38+
39+
**To the Portal you configured.** Your tunnelled traffic, plus a 32-byte
40+
authentication frame per connection. The Portal necessarily sees the addresses
41+
and names you connect to — that is what a tunnel is. What it does with them is
42+
its operator's policy, not this app's, and this app has no way to constrain it.
43+
44+
**To the subscription URL you entered**, when you add or refresh a
45+
subscription. An ordinary HTTPS `GET`, carrying three query parameters that
46+
describe this client so a dashboard can generate a node list it can actually
47+
use:
48+
49+
- `type` — that this is a Somewhere client
50+
- `ver` — the client version
51+
- `caps` — which protocol features this build supports
52+
53+
No identifier of you, your device or your installation is sent. There is none
54+
to send: this app generates no installation id.
55+
56+
**To the addresses you connect to**, for traffic your routing rules send
57+
*direct* rather than through the tunnel. That traffic does not reach the Portal
58+
at all, which is the point of a direct rule, and it is not otherwise treated
59+
differently.
60+
61+
**Nowhere else.** No third party receives anything.
62+
63+
## What is not collected
64+
65+
Named individually, because for an app of this kind the absences are the part
66+
worth reading:
67+
68+
- no advertising identifier, and no advertising
69+
- no installation id, device id, IMEI, MAC address or serial number
70+
- no location, coarse or fine
71+
- no contacts, calendar, photos, files or clipboard
72+
- no list of installed applications **leaves the device** — the per-app screen
73+
reads one to draw itself and it is never transmitted
74+
- no browsing history: the app does not keep one, and the connection log is
75+
in-memory, capped at 200 entries, and gone when the process ends
76+
- no crash reports, no usage statistics, no "improve the product" data
77+
78+
## The connection log
79+
80+
The Diagnostics screen shows what the Portal answered for recent flows: the
81+
result, the time, a destination as host and port, a flow id, and which carrier
82+
carried it. It exists in memory only, holds the most recent 200 entries, and is
83+
lost when the app's process ends.
84+
85+
It is **built so that a credential cannot appear in it**. An entry has five
86+
fields and none of them can hold a shared key, a subscription URL or a token —
87+
there is no room for one — and a test asserts that field list so a future entry
88+
that grew one fails rather than shipping. A destination is reduced to a host and
89+
a port before it is stored. This matters because people paste logs into public
90+
issue trackers, and helpfulness is how credentials get published.
91+
92+
## Permissions, and what each is for
93+
94+
| Permission | Why |
95+
|---|---|
96+
| `INTERNET` | To reach the Portal and the subscription URL |
97+
| `ACCESS_NETWORK_STATE` | To notice when the network changes under a live tunnel |
98+
| `BIND_VPN_SERVICE` | To establish the tunnel. Android shows its own consent dialog and the tunnel cannot start without it |
99+
| `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_SPECIAL_USE` | A tunnel must survive the app being backgrounded, which is the normal case: nobody watches a VPN |
100+
| `POST_NOTIFICATIONS` | To show the ongoing notification that says a tunnel is running. Without it the tunnel runs with nothing on screen saying so |
101+
102+
The app declares a `<queries>` entry for applications with a launcher icon so
103+
the per-app screen can list them. It does **not** request
104+
`QUERY_ALL_PACKAGES`, and the screen says its list is partial rather than
105+
pretending otherwise.
106+
107+
## Children
108+
109+
This app is not directed at children and collects nothing from anyone.
110+
111+
## Changes
112+
113+
This document lives in the repository and changes with it. Its history is the
114+
change log: `git log docs/privacy.md`.
115+
116+
## Contact
117+
118+
Security reports: see [`SECURITY.md`](../SECURITY.md). Anything else: the issue
119+
tracker of this repository.

‎docs/store-policy.md‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# Store policy for a VPN-class app
2+
3+
> Checked 2026-08-29, before any submission. **Nothing has been submitted**, and
4+
> this is a reading of published policy rather than advice or an approval.
5+
6+
A VPN app is one of the most heavily conditioned categories a store has, and
7+
every condition below is one that gets an app removed rather than merely
8+
delayed. They are recorded now because the expensive ones are architectural: a
9+
policy discovered at submission that requires a different design is a rewrite,
10+
and a policy discovered after publication is a removal.
11+
12+
## What this app already satisfies
13+
14+
| Requirement | Where it stands |
15+
|---|---|
16+
| Declare `VpnService` and use the platform's own consent flow | `SomewhereVpnService` extends `VpnService`; Android shows its dialog and the tunnel cannot start without it |
17+
| A foreground service with a visible ongoing notification | Present, with `specialUse` and its subtype declared — Android has no VPN foreground type, which is why |
18+
| Do not use `VpnService` to collect, monitor or redirect user traffic for undisclosed purposes | The app is a transport to a server the user configured. It has no upload path of its own |
19+
| Do not use the VPN to circumvent store policy or bill outside it | No purchases, no billing, no ads |
20+
| A privacy policy, linked from the listing | [`privacy.md`](privacy.md) |
21+
| Prominent disclosure of what a sensitive permission is used for | The consent dialog is the platform's; the notification says a tunnel is running; the privacy policy names every permission |
22+
| No undisclosed reading of installed applications | `<queries>` for launcher-visible apps only, never `QUERY_ALL_PACKAGES`, and the screen says the list is partial |
23+
| Data safety declaration matching behaviour | Answerable directly from `privacy.md`: nothing is collected, nothing is shared |
24+
25+
## What is still open, and who owns it
26+
27+
**The package identifier — the requester's, and the only one that cannot be
28+
undone.** `eu.nodepass.somewhere` uses the organisation's domain rather than one
29+
this project controls. It is baked into the signing identity, the listing and
30+
the user data directory, and changing it after publication ships a different app
31+
with installs and ratings at zero. D-02b says it is free to change until the
32+
first signed, published build; the first signed build now exists and nothing has
33+
been published, so this is the moment.
34+
35+
**A signing key, and where it lives.** There is none. Whoever creates it holds
36+
the app's identity for its lifetime: a lost key means no further updates, ever,
37+
under that identifier.
38+
39+
**Encryption export compliance.** The app uses TLS and QUIC and statically links
40+
aws-lc. Most jurisdictions exempt an application whose cryptography is limited to
41+
protecting the user's own communications, and store declarations usually ask a
42+
single yes/no. It is listed here because "we forgot to answer the export
43+
question" is a common cause of a rejected first submission, not because it is
44+
expected to be difficult.
45+
46+
**A distribution channel (D-05).** Store track, direct APK, or both. Not decided,
47+
and the answer changes what else is needed: a direct APK needs an update path of
48+
its own, and a store track needs a developer account, a listing and screenshots.
49+
50+
**Whether `QUERY_ALL_PACKAGES` is ever wanted (D-16).** Currently not requested.
51+
If the launcher-visible list turns out to be materially incomplete on real
52+
hardware, the fallback is a build flavour split, and a store build would be the
53+
one without it.
54+
55+
## What is deliberately not claimed
56+
57+
**This app does not claim to resist censorship, and its listing must not.**
58+
Nowhere is not an anti-censorship protocol and does not present itself as one:
59+
the words obfuscation, censorship, active probing and DPI appear zero times in
60+
upstream's own `docs/protocol.md` and `docs/security.md`. A listing that implied
61+
otherwise would be both a false claim and, in several jurisdictions, a
62+
materially riskier one to make.
63+
64+
**It is not a "free VPN".** It carries no service. A user brings their own
65+
Portal, and without one the app does nothing at all. That is worth saying
66+
plainly on any listing, because the category's reputation is built on apps that
67+
work the other way round.

0 commit comments

Comments
 (0)