Skip to content

Commit 9ca1589

Browse files
AlbertoDeckowclaude
andcommitted
Merge nw3: follow upstream to Nowhere v3.0.1
Somewhere now speaks Nowhere v3 (nw3) and nothing older: role-key authentication, the one-byte FlowStart, five setup outcomes, TLS one connection per flow, QUIC with stream-derived flow ids, Morph on both carriers, and certificates always verified. Mux, split carriers and session pairing are removed, as upstream removed them. D-21. The protocol baseline moves from v1.8.2 (05366539) to v3.0.1 (00000e0), which resolves the drift alert. Verified: 86 vectors, 634 unit tests, every quality gate, the oracle differential agreeing with v3.0.1 on every case over TLS and QUIC with Morph on and off, and the instrumentation and end-to-end suites on an API 35 emulator. Not on physical hardware. Closes #2 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 parents 8f16af2 + 2713902 commit 9ca1589

153 files changed

Lines changed: 6409 additions & 10343 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/ISSUE_TEMPLATE/bug.yml‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,9 @@ body:
5050
attributes:
5151
label: Configuration shape
5252
description: >
53-
The transports, mux and TLS settings involved — with every key, token and
54-
real host removed.
55-
placeholder: "tcp/tcp, mux=1, no sni, no pin"
53+
The carrier, Morph and certificate settings involved — with every key,
54+
token and real host removed.
55+
placeholder: "net=udp, morph=1, pin"
5656

5757
- type: textarea
5858
id: logs

‎.github/ISSUE_TEMPLATE/protocol.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,4 @@ body:
4646
attributes:
4747
label: Baseline checked against
4848
description: The commit in `conformance/PROTOCOL_BASELINE` you compared with.
49-
placeholder: v1.8.0 @ 7720311b
49+
placeholder: v3.0.1 @ 00000e0

‎.github/workflows/ci.yml‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,9 @@ jobs:
8787
runs-on: ubuntu-latest
8888
# Two things that a JVM cannot check.
8989
#
90-
# The design rules from docs/design-system.md — the certificate marker, the
91-
# seven distinct rejections, the two-button QUIC notice, the absence of an
92-
# upload figure — because nothing crashes when they are violated.
90+
# The design rules from docs/design-system.md — the certificate statement,
91+
# the distinct rejections, the Morph switch, the absence of an upload
92+
# figure — because nothing crashes when they are violated.
9393
#
9494
# And the lwIP layer, because a JVM cannot load an .so built for Android:
9595
# until this job runs, the whole C layer is code that merely compiled.

‎CLAUDE.md‎

Lines changed: 120 additions & 97 deletions
Large diffs are not rendered by default.

‎README.md‎

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

99
## Status
1010

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.
11+
Pre-release, and **not published anywhere**. The client speaks **Nowhere v3**
12+
(`nw3`) and nothing older: a share link `nowhere://key@host:port` — TLS, Morph
13+
on, the v3 defaults — or the same with `?net=udp` for QUIC, connects and carries
14+
traffic.
1515

1616
| | |
1717
|---|---|
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 |
18+
| Protocol | v3 authentication with role keys, the one-byte FlowStart, the five setup outcomes, UDP over a stream, stream-derived QUIC flow ids, UDP over DATAGRAM with fragmentation |
19+
| Morph | The v3 wire mask, on by default: ChaCha20 under TLS with the `fade` prelude, and per datagram under QUIC |
20+
| Carriers | TLS (one connection per flow) and QUIC (one connection for all of them), each with Morph on and off, compared case by case against the reference implementation, which agrees on every one |
21+
| Certificates | Verified on both carriers, always: a pin, or this device's trust store and the hostname. v3 has no unverified mode |
2222
| Tunnel | `VpnService` + lwIP, **both address families**, TCP with back-pressure, UDP, DNS interception with fake-IP over IPv4 and IPv6, live throughput |
2323
| Routing | A rule matcher, an import path, a bundled network-structural rule set for both families, a direct path, a reject path |
2424
| Per-app | A real installed list, a persisted selection, and this client structurally outside its own tunnel |
@@ -36,9 +36,13 @@ which is a QUIC node, because the specification's default for both directions is
3636
tunnel before applications run and can block traffic while it is down; an
3737
in-app boot receiver would be a worse version of it. Settings links to the
3838
system switch instead.
39-
- **The QUIC carrier does not verify a certificate chain.** `pin` works on both
40-
carriers; a node carrying `sni` is refused rather than carried without it. See
41-
[ADR-0002](docs/adr-0002-quic-certificate-verification.md).
39+
- **Verification by name has only been seen to refuse on a device.** Accepting a
40+
chain this device trusts has been exercised on a JVM and against a live Portal
41+
with a test anchor, not on Android, because that needs a CA whose key a test
42+
can sign with. See [ADR-0002](docs/adr-0002-quic-certificate-verification.md).
43+
- **The dashboard does not emit v3 links yet.** NowhereDash targets Nowhere
44+
2.1.1; its `up`/`down` links are read as `net` where the two agree, and its
45+
carrier-path form is refused with a reason.
4246
- **No release has been published**, and no signing key exists.
4347

4448
## Roadmap
@@ -50,44 +54,42 @@ There is no big-bang release.
5054
| Stage | What it delivers | State |
5155
|---|---|---|
5256
| **M0** · foundations | Public repository, CI, the quality gates, and the conformance suite the later layers are judged by | **Done**, 2026-08-24 |
53-
| **L1** · TLS over TCP | Authentication, FlowHeader, Target, the seven setup outcomes, the dedicated lane, UDP over a stream — and, pulled forward because a client nobody can feed nodes to cannot be tested, the TUN, DNS with fake-IP, node storage and subscription import | **Done**, 2026-08-27 |
54-
| **L2** · TLS Mux | The Mux carrier at upstream's own shard density, and concurrency measured over it | **Done**, 2026-08-27 |
55-
| **L3** · QUIC | ngtcp2 over aws-lc, authentication, TCP over reliable streams, UDP over DATAGRAM, split flows across two carriers, keep-alive, and every carrier combination compared against the reference implementation | **Done**, 2026-08-29 |
57+
| **L1** · TLS over TCP | Authentication, flow setup, Target, the setup outcomes, the dedicated lane, UDP over a stream — and, pulled forward because a client nobody can feed nodes to cannot be tested, the TUN, DNS with fake-IP, node storage and subscription import | **Done**, 2026-08-27, against v1.8 |
58+
| **L2** · TLS Mux | The Mux carrier at upstream's own shard density | **Done**, 2026-08-27 — and **removed** with v3, which deleted Mux |
59+
| **L3** · QUIC | ngtcp2 over aws-lc, authentication, TCP over reliable streams, UDP over DATAGRAM, keep-alive, every carrier compared against the reference implementation | **Done**, 2026-08-29, against v1.8 |
5660
| **Release tail** | A bundled rule set, a connection log that shows what the Portal actually said, a launcher icon, a release build that has been run rather than only produced, and the documents a VPN-class listing requires | **Done**, 2026-08-29 |
5761
| **L4** · product surface | IPv6 end to end, `pin` on the QUIC carrier, node health and failover, a scheduled subscription refresh and a quick-settings tile | **Done**, 2026-08-29 |
62+
| **v3** · following upstream | Nowhere v3.0.1: the new authentication, FlowStart and setup outcomes; Mux and split carriers removed; stream-derived QUIC flow ids; Morph on both carriers; chain verification by name on QUIC; every conformance claim re-established against the v3 oracle and on an emulator | **Done**, 2026-10-21 |
5863
| **Device pass** | The first run on physical hardware: Private DNS, a real per-app list, a Wi-Fi to cellular change, Doze, a path MTU below 1500 | **Not started.** No physical device has ever been attached to this project |
59-
| **L5** · what the device pass asks for | Chain verification against `sni` on QUIC ([ADR-0002](docs/adr-0002-quic-certificate-verification.md)), and whatever a real phone turns out to need | **Not started**, deliberately: scope fixed after a device pass, not before |
64+
| **L5** · what the device pass asks for | Whatever a real phone turns out to need | **Not started**, deliberately: scope fixed after a device pass, not before |
6065
| **Publication** | A signing key, a distribution channel, a first release | **Waiting on decisions rather than on code** |
6166

6267
### How much of it is done
6368

64-
[`conformance/cases/l1-coverage.md`](conformance/cases/l1-coverage.md) is the
69+
[`conformance/cases/coverage.md`](conformance/cases/coverage.md) is the
6570
measure. It carries one row per specification obligation, and every row either
66-
names the test that covers it or says what it is waiting for — so these counts
67-
can be re-derived by reading it rather than taken on trust. They were wrong once,
71+
names the test that covers it or says why it has none — so these counts can be
72+
re-derived by reading it rather than taken on trust. They were wrong once,
6873
carried forward through two runs by hand, and that is why they are now counted
6974
from the table.
7075

71-
| Layer | Rows | Covered by a test | Partly, with the gap named | Blocked |
76+
| Rows | Covered by a test | Manual, with the reason | Partly, with the gap named | Blocked |
7277
|---|---|---|---|---|
73-
| L1 | 63 | 62 | 1 | 0 |
74-
| L2 | 23 | 22 | 1 | 0 |
75-
| L3 | 27 | 25 | 2 | 0 |
76-
| **Total** | **113** | **109** | **4** | **0** |
78+
| 89 | 88 | 1 | 2 | 0 |
7779

78-
The four partial rows are each half of a pair: this client's half is tested, and
79-
the other half needs something no repository can contain — a running dashboard, a
80-
Portal that refuses stream credit, a phone.
80+
The two partial rows are each half of a pair: this client's half is tested, and
81+
the other half needs something no repository should contain — a dashboard that
82+
emits v3 links, and a CA key a test could sign with.
8183

8284
**The protocol is finished and the product is not.** Between this repository and
83-
a release stand a physical device, a decision about certificate verification on
84-
the QUIC carrier, and a signing key that will never live here.
85+
a release stand a physical device and a signing key that will never live here.
8586

8687
## Protocol baseline
8788

8889
Pinned in [`conformance/PROTOCOL_BASELINE`](conformance/PROTOCOL_BASELINE):
89-
Nowhere **v1.8.2**. Every protocol implementation detail is traceable to a
90-
specific section of the upstream `docs/protocol.md`.
90+
Nowhere **v3.0.1** (`00000e0`). Every protocol implementation detail is
91+
traceable to a specific section of the upstream `docs/protocol.md`. v3 is not
92+
wire-compatible with v1: a v3 client cannot reach a v1 Portal, or the reverse.
9193

9294
## Conformance suite
9395

@@ -97,28 +99,29 @@ deliberately: third-party implementations need it to align, dashboard and Portal
9799
interop checks need it, and community bug reports need it to be meaningful.
98100

99101
```sh
100-
python3 conformance/scripts/verify-vectors.py # 45 known-answer checks
102+
python3 conformance/scripts/verify-vectors.py # 86 known-answer checks
101103
NOWHERE_CLONE=/path/to/Nowhere conformance/scripts/drift-check.sh
102104
conformance/scripts/oracle-diff.sh # this client against upstream's own
103105
```
104106

105107
`oracle-diff.sh` is the one worth running. It puts the same case list through
106-
this implementation and the reference client, over every carrier combination
107-
both support, and diffs the outcomes. Its only finding to date was a protocol
108-
fact that had grown a second shape the moment a second carrier landed — invisible
109-
to every test that asserted on one carrier's own types, and about to degrade
110-
seven distinct rejection messages to a generic failure on a user's screen.
108+
this implementation and the reference client, over both carriers with Morph on
109+
and off, and diffs the outcomes. Against v1 it found a rejection that one
110+
carrier reported in a form a caller could not read; on its first v3 run it found
111+
a refused AuthFrame over QUIC reported as a transport failure, where the oracle
112+
and the TLS carrier both read it as no answer.
111113

112114
The suite makes no assumption about where an upstream clone lives; point
113115
`NOWHERE_CLONE` at one.
114116

115117
### Tests that need a live Portal
116118

117119
Many tests dial a real Portal rather than a fake, because the failures that
118-
matter most are the ones no fixture predicts — a rejected authentication frame
119-
is met with **silence** rather than a close, which a fake returning EOF will
120-
never teach you. They skip cleanly when no Portal is running, so an environment
121-
that cannot run them reports honestly instead of passing vacuously.
120+
matter most are the ones no fixture predicts. The first run against v3 found the
121+
clearest example: a Portal ends a flow whose first payload arrives before
122+
READY, which no vector and no fake would have shown. They skip cleanly when no
123+
Portal is running, so an environment that cannot run them reports honestly
124+
instead of passing vacuously.
122125

123126
```sh
124127
eval "$(conformance/scripts/portal-for-tests.sh)"
@@ -133,8 +136,9 @@ conformance/scripts/portal-for-tests.sh --stop
133136
| [`docs/design-system.md`](docs/design-system.md) | Colour tokens for both themes with measured contrast, the type scale, and the four rules the UI has to keep |
134137
| [`docs/brand.md`](docs/brand.md) | The brand hue, which carries no protocol meaning and is derived rather than picked |
135138
| [`docs/i18n.md`](docs/i18n.md) | The three shipping locales, and the rule that machine identifiers stay English while sentences translate |
136-
| [`docs/adr-0001-tls-exporter.md`](docs/adr-0001-tls-exporter.md) | Why the TLS exporter has two sources, and why `minSdk` is 26 |
137-
| [`docs/adr-0002-quic-certificate-verification.md`](docs/adr-0002-quic-certificate-verification.md) | How `pin` is verified on the QUIC carrier, and what `sni` would cost — with the estimate that made it look expensive named as the wrong option |
139+
| [`docs/adr-0001-tls-exporter.md`](docs/adr-0001-tls-exporter.md) | Where the TLS exporter comes from, and why `minSdk` is 26 |
140+
| [`docs/adr-0002-quic-certificate-verification.md`](docs/adr-0002-quic-certificate-verification.md) | How certificates are verified on the QUIC carrier: `pin` in the handshake, a name against the platform's trust store after it |
141+
| [`docs/adr-0003-morph.md`](docs/adr-0003-morph.md) | How Morph is applied under each carrier, and why TLS is now Conscrypt in engine mode everywhere |
138142
| [`docs/privacy.md`](docs/privacy.md) | What is stored, what leaves the device, and the longer list of what is not collected |
139143
| [`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 |
140144

@@ -202,11 +206,12 @@ for the private reporting channel and what is in scope.
202206

203207
Two properties worth stating plainly:
204208

205-
- **A node with neither `sni` nor `pin` gets no certificate verification**, which
206-
is what upstream does and what every URL current dashboards emit produces. The
207-
client states this persistently rather than hiding or refusing it.
208-
- **Nowhere is not an anti-censorship protocol and does not claim to be.** This
209-
client must not imply otherwise.
209+
- **Every node's certificate is verified**, by a pin or by this device's trust
210+
store and the hostname. v3 removed the mode that skipped it; a Portal on its
211+
generated certificate needs a pin.
212+
- **Morph masks the wire; it does not protect anything.** Upstream says so —
213+
"wire masking, not authentication or integrity" — and makes no claim that
214+
Nowhere resists censorship. This client must not imply otherwise.
210215

211216
## Licence
212217

‎app/build.gradle.kts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ android {
7373
// form a script uses when it varies a setting between runs — that form
7474
// reaches `am instrument` without changing the APK, so it does not
7575
// trigger a reinstall, and a reinstall clears the VPN consent grant.
76-
listOf("nowhereE2ePortal", "nowhereE2eKey", "nowhereE2eTarget", "nowhereE2eOrigin", "nowhereE2eMux")
76+
listOf("nowhereE2ePortal", "nowhereE2eKey", "nowhereE2ePin", "nowhereE2eMorph", "nowhereE2eTarget", "nowhereE2eOrigin")
7777
.forEach { name ->
7878
(project.findProperty(name) as String?)?.takeIf { it.isNotBlank() }?.let {
7979
testInstrumentationRunnerArguments[name] = it
@@ -561,7 +561,7 @@ abstract class NativeBridgeChecksTask : DefaultTask() {
561561
}
562562

563563
// A second source-level rule, in the same file and of the same kind.
564-
// Section 3 of the specification says unidirectional streams are not
564+
// The specification says unidirectional streams are not
565565
// used. This client advertises initial_max_streams_uni = 0, so the
566566
// statement is on the wire — but nothing stops a later change from
567567
// opening one, and a stream the peer will never read is a flow that
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// SPDX-License-Identifier: GPL-3.0-only
2+
// Copyright (C) 2026 The Somewhere Authors
3+
4+
package eu.nodepass.somewhere.quic
5+
6+
import androidx.test.ext.junit.runners.AndroidJUnit4
7+
import eu.nodepass.somewhere.net.DialReason
8+
import eu.nodepass.somewhere.net.NowhereDialer
9+
import eu.nodepass.somewhere.protocol.url.CertificateVerification
10+
import eu.nodepass.somewhere.vpn.E2eEnvironment
11+
import org.junit.Assert.assertEquals
12+
import org.junit.Assert.assertTrue
13+
import org.junit.Test
14+
import org.junit.runner.RunWith
15+
16+
/**
17+
* Verification by name, against the platform's own trust store, on a device.
18+
*
19+
* The JVM tests exercise [eu.nodepass.somewhere.protocol.tls.PeerVerification]
20+
* with a test anchor; what only a device can run is the platform half —
21+
* `X509TrustManagerExtensions` with the host — because on a JVM that branch is
22+
* not taken. These cases are the refusal: a Portal on its generated
23+
* certificate, dialled by name with no pin, must be refused on both carriers,
24+
* and refused *as a trust failure* rather than by an exception from somewhere
25+
* else, which is what an authentication type the platform did not accept would
26+
* look like.
27+
*
28+
* The acceptance half needs a certificate this device trusts, which means a
29+
* CA whose key exists somewhere a test can sign with. Not done: committing
30+
* such a key would make every debug build trust whoever reads this repository.
31+
*/
32+
@RunWith(AndroidJUnit4::class)
33+
class ChainVerificationOnDeviceTest {
34+
private fun byName(net: String) =
35+
E2eEnvironment.node(net = net).let { it.copy(certificateVerification = CertificateVerification.Sni(it.host)) }
36+
37+
@Test
38+
fun aGeneratedCertificateIsRefusedOverTls() {
39+
val result = NowhereDialer(protect = { true }).connect(byName("tcp"))
40+
val reason = result.reasonOrNull()
41+
assertTrue("expected a trust refusal, got ${reason?.detail}", reason is DialReason.Untrusted)
42+
}
43+
44+
@Test
45+
fun aGeneratedCertificateIsRefusedOverQuic() {
46+
val failure =
47+
runCatching { E2eEnvironment.openQuic(byName("udp")).use { it.completeHandshake() } }.exceptionOrNull()
48+
assertTrue("expected a QUIC failure, got $failure", failure is QuicException)
49+
val reason = (failure as QuicException).failure
50+
assertTrue("expected a trust refusal, got ${reason.detail}", reason is QuicFailure.Untrusted)
51+
}
52+
53+
@Test
54+
fun aRefusedConnectionIsNotLeftOpen() {
55+
val before = QuicConnection.liveConnections()
56+
runCatching { E2eEnvironment.openQuic(byName("udp")).use { it.completeHandshake() } }
57+
assertEquals(before, QuicConnection.liveConnections())
58+
}
59+
}

0 commit comments

Comments
 (0)