Skip to content

Commit 5c66fed

Browse files
committed
feat(desktop): ship the real signed directory + pin the directory key
The desktop shipped placeholder gateways and an empty directory pubkey, so it couldn't trust/discover the live fleet like mobile does. Now it ships the real artifact. - Bundle deploy/directory/directory.signed.json (identical to the mobile app's) as src/data/directory.json, and pin the real CVPN_DIRECTORY_PUBKEY (1e+42…KKw=). directory.ts now loads + can verify it via core directoryVerify, mirroring the mobile client's trust anchor. - A test asserts the bundled snapshot actually verifies against the pinned key, so we can't ship a directory whose signature doesn't match. - Filter 0.0.0.0 placeholder seeds in the offline cold-start (like mobile): live discovery resolves real IPs, so an unreachable fallback shows nothing rather than an unconnectable gateway. Verified via a headless render: the desktop now discovers 🇩🇪 Germany from the verified directory and the UI is fully live (Connect enabled, health dot). All checks green (tsc, eslint, prettier, 12 tests).
1 parent 934727d commit 5c66fed

5 files changed

Lines changed: 77 additions & 45 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"version": 1,
3+
"updated": "2026-07-17T14:12:07Z",
4+
"payment_address": "t3disq3aZz8K3RLZL9zfkpP2UWNVV3hq4vZ",
5+
"price_flux": 20,
6+
"specs": [
7+
"cumulusvpnau",
8+
"cumulusvpnbr",
9+
"cumulusvpnca",
10+
"cumulusvpncz",
11+
"cumulusvpnde",
12+
"cumulusvpnfr",
13+
"cumulusvpngb",
14+
"cumulusvpnjp",
15+
"cumulusvpnnl",
16+
"cumulusvpnpl",
17+
"cumulusvpnsg",
18+
"cumulusvpnus"
19+
],
20+
"seed_gateways": [
21+
{
22+
"ip": "0.0.0.0",
23+
"country": "DE",
24+
"sign_pubkey": "REPLACE_GATEWAY_ED25519_PUBKEY_B64"
25+
}
26+
],
27+
"sign_pubkey": "1e+42nEpmdjf/cAHs+yE2E2iwmAADpWiLy1VMepsKKw=",
28+
"sig": "TsQTer0bKqMjb0mUZGKwwy5Lr0bDVaUKXynxZRoCJKk3Ps7D4Q/AREEgAZWuTT6Ob7SZQmRpf8ARwo4rK9LHAA=="
29+
}

clients/desktop/src/lib/directory.test.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { describe, expect, it } from 'vitest';
2-
import { BUNDLED_DIRECTORY, UPGRADE_URL, countryMeta } from './directory';
2+
import {
3+
BUNDLED_DIRECTORY,
4+
CVPN_DIRECTORY_PUBKEY,
5+
UPGRADE_URL,
6+
bundledDirectoryIsValid,
7+
countryMeta,
8+
} from './directory';
39

410
describe('countryMeta', () => {
511
it('resolves display metadata for known countries', () => {
@@ -31,6 +37,13 @@ describe('BUNDLED_DIRECTORY', () => {
3137
expect(BUNDLED_DIRECTORY.specs).toContain('cumulusvpnde');
3238
expect(BUNDLED_DIRECTORY.specs.every((s) => s.startsWith('cumulus'))).toBe(true);
3339
});
40+
41+
it('is the real signed snapshot that verifies against the pinned key', () => {
42+
// Guards against shipping a directory whose signature does not match the
43+
// pinned CVPN_DIRECTORY_PUBKEY (the whole trust chain hangs off this).
44+
expect(CVPN_DIRECTORY_PUBKEY).not.toBe('');
45+
expect(bundledDirectoryIsValid()).toBe(true);
46+
});
3447
});
3548

3649
describe('UPGRADE_URL', () => {

clients/desktop/src/lib/directory.ts

Lines changed: 25 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -2,48 +2,32 @@
22
* Bundled signed `directory.json` snapshot + the directory Ed25519 pubkey the
33
* client ships and pins. Cold-start / all-unreachable fallback per
44
* `docs/10-api-contract.md` (order: disk cache → live discovery → this
5-
* snapshot). Every path is signature-checked via `core.directoryVerify`.
5+
* snapshot). Every path is signature-checked via core `directoryVerify`.
66
*
7-
* // POC: these are placeholder seed endpoints + an unset pinned key. A real
8-
* release regenerates this file from cumulusvpn.com and the Tauri updater ships
9-
* a fresh snapshot with every binary. `verifyDirectory()` is wired but the
10-
* bundled `sig` here is a placeholder, so we treat an unset pin as "trust on
11-
* first probe" during the POC.
7+
* The snapshot here is the real artifact published from cumulusvpn.com (a copy
8+
* of `deploy/directory/directory.signed.json`), identical to the one the mobile
9+
* app ships, so the desktop discovers the same live fleet. Live discovery
10+
* resolves the `specs` to gateway IPs through Flux; the `seed_gateways` are only
11+
* the emergency direct-probe fallback.
1212
*/
13+
import { directoryVerify } from '@cumulusvpn/core';
1314
import type { Directory } from '@cumulusvpn/core';
15+
import bundled from '../data/directory.json';
1416

15-
/** Base64 Ed25519 directory public key clients pin. // POC: unset placeholder. */
16-
export const CVPN_DIRECTORY_PUBKEY = '';
17+
/**
18+
* Pinned directory Ed25519 public key (`CVPN_DIRECTORY_PUBKEY`), base64.
19+
* Baked into the binary; the whole trust chain hangs off this constant. Matches
20+
* the mobile app's pin so both clients trust the same directory signer.
21+
*/
22+
export const CVPN_DIRECTORY_PUBKEY = '1e+42nEpmdjf/cAHs+yE2E2iwmAADpWiLy1VMepsKKw=';
1723

18-
/** Last-known fleet snapshot baked into the release. */
19-
export const BUNDLED_DIRECTORY: Directory = {
20-
version: 1,
21-
updated: '2026-07-01T00:00:00Z',
22-
payment_address: 't1PLACEHOLDERpaymentAddressREPLACEatRelease0',
23-
price_flux: 20,
24-
specs: [
25-
'cumulusvpnau',
26-
'cumulusvpnbr',
27-
'cumulusvpnca',
28-
'cumulusvpncz',
29-
'cumulusvpnde',
30-
'cumulusvpnfr',
31-
'cumulusvpngb',
32-
'cumulusvpnjp',
33-
'cumulusvpnnl',
34-
'cumulusvpnpl',
35-
'cumulusvpnsg',
36-
'cumulusvpnus',
37-
],
38-
seed_gateways: [
39-
{ ip: '203.0.113.10', country: 'DE', sign_pubkey: '' },
40-
{ ip: '203.0.113.20', country: 'US', sign_pubkey: '' },
41-
{ ip: '203.0.113.30', country: 'NL', sign_pubkey: '' },
42-
],
43-
// POC: placeholder signature; regenerated + verified against the pinned key
44-
// in production. See discovery.directoryVerify.
45-
sig: '',
46-
};
24+
/** Last-known fleet snapshot baked into the release, typed as core `Directory`. */
25+
export const BUNDLED_DIRECTORY: Directory = bundled as Directory;
26+
27+
/** True iff the bundled snapshot verifies against the pinned directory key. */
28+
export function bundledDirectoryIsValid(): boolean {
29+
return directoryVerify(BUNDLED_DIRECTORY, CVPN_DIRECTORY_PUBKEY);
30+
}
4731

4832
/** Human display metadata for the countries the fleet serves. */
4933
export interface CountryMeta {
@@ -62,6 +46,10 @@ const COUNTRY_META: Record<string, CountryMeta> = {
6246
FR: { code: 'FR', name: 'France', flag: '🇫🇷' },
6347
CA: { code: 'CA', name: 'Canada', flag: '🇨🇦' },
6448
JP: { code: 'JP', name: 'Japan', flag: '🇯🇵' },
49+
AU: { code: 'AU', name: 'Australia', flag: '🇦🇺' },
50+
BR: { code: 'BR', name: 'Brazil', flag: '🇧🇷' },
51+
CZ: { code: 'CZ', name: 'Czechia', flag: '🇨🇿' },
52+
PL: { code: 'PL', name: 'Poland', flag: '🇵🇱' },
6553
};
6654

6755
/** Resolve display metadata for an ISO country code, with a safe fallback. */

clients/desktop/src/lib/session.test.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,16 +67,15 @@ describe('discoverCountries', () => {
6767
expect(de.signPubKey).toBe('sign-de');
6868
});
6969

70-
it('falls back to the bundled seed gateways when nothing is reachable', async () => {
70+
it('drops 0.0.0.0 placeholder seeds when nothing is reachable', async () => {
7171
mockedDiscover.mockResolvedValue([]);
7272

7373
const options = await discoverCountries();
7474

75-
expect(options.map((o) => o.code)).toEqual(['DE', 'NL', 'US']);
76-
for (const o of options) {
77-
expect(o.load).toBe(0);
78-
expect(o.city).toBe('');
79-
}
75+
// The real signed directory ships only placeholder (0.0.0.0) seeds — live
76+
// discovery resolves the real IPs — so the offline fallback is empty rather
77+
// than a list of unconnectable gateways (matches the mobile client).
78+
expect(options).toEqual([]);
8079
});
8180
});
8281

clients/desktop/src/lib/session.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,11 @@ export async function discoverCountries(fetchImpl?: typeof fetch): Promise<Count
101101
if (gateways.length > 0) {
102102
return toCountryOptions(gateways);
103103
}
104-
// Offline cold-start: synthesize options from the signed seed list.
104+
// Offline cold-start: synthesize options from the signed seed list. Skip the
105+
// 0.0.0.0 placeholder seeds (live discovery resolves real IPs), matching the
106+
// mobile client — better to show nothing than an unconnectable gateway.
105107
return BUNDLED_DIRECTORY.seed_gateways
108+
.filter((seed) => seed.ip !== '0.0.0.0')
106109
.map((seed): CountryOption => {
107110
const meta = countryMeta(seed.country);
108111
return {

0 commit comments

Comments
 (0)