feat(auth,client-registry): add Noise IK client authentication with managed client registry and per-client ACL controls

This commit is contained in:
2026-03-29 17:04:27 +00:00
parent 187a69028b
commit 01a0d8b9f4
20 changed files with 1930 additions and 897 deletions

View File

@@ -1,7 +1,7 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as net from 'net';
import { VpnClient, VpnServer } from '../ts/index.js';
import type { IVpnClientOptions, IVpnServerOptions, IVpnKeypair, IVpnServerConfig } from '../ts/index.js';
import type { IVpnClientOptions, IVpnServerOptions, IVpnKeypair, IVpnServerConfig, IClientConfigBundle } from '../ts/index.js';
// ---------------------------------------------------------------------------
// Helpers
@@ -40,7 +40,9 @@ let server: VpnServer;
let serverPort: number;
let keypair: IVpnKeypair;
let client: VpnClient;
let clientBundle: IClientConfigBundle;
const extraClients: VpnClient[] = [];
const extraBundles: IClientConfigBundle[] = [];
// ---------------------------------------------------------------------------
// Tests
@@ -64,7 +66,7 @@ tap.test('setup: start VPN server', async () => {
expect(keypair.publicKey).toBeTypeofString();
expect(keypair.privateKey).toBeTypeofString();
// Phase 3: start the VPN listener
// Phase 3: start the VPN listener (empty clients, will use createClient at runtime)
const serverConfig: IVpnServerConfig = {
listenAddr: `127.0.0.1:${serverPort}`,
privateKey: keypair.privateKey,
@@ -76,6 +78,11 @@ tap.test('setup: start VPN server', async () => {
// Verify server is now running
const status = await server.getStatus();
expect(status.state).toEqual('connected');
// Phase 4: create the first client via the hub
clientBundle = await server.createClient({ clientId: 'test-client-0' });
expect(clientBundle.secrets.noisePrivateKey).toBeTypeofString();
expect(clientBundle.smartvpnConfig.clientPublicKey).toBeTypeofString();
});
tap.test('single client connects and gets IP', async () => {
@@ -89,6 +96,8 @@ tap.test('single client connects and gets IP', async () => {
const result = await client.connect({
serverUrl: `ws://127.0.0.1:${serverPort}`,
serverPublicKey: keypair.publicKey,
clientPrivateKey: clientBundle.secrets.noisePrivateKey,
clientPublicKey: clientBundle.smartvpnConfig.clientPublicKey,
keepaliveIntervalSecs: 3,
});
@@ -175,11 +184,15 @@ tap.test('5 concurrent clients', async () => {
assignedIps.add(existingClients[0].assignedIp);
for (let i = 0; i < 5; i++) {
const bundle = await server.createClient({ clientId: `test-client-${i + 1}` });
extraBundles.push(bundle);
const c = new VpnClient({ transport: { transport: 'stdio' } });
await c.start();
const result = await c.connect({
serverUrl: `ws://127.0.0.1:${serverPort}`,
serverPublicKey: keypair.publicKey,
clientPrivateKey: bundle.secrets.noisePrivateKey,
clientPublicKey: bundle.smartvpnConfig.clientPublicKey,
keepaliveIntervalSecs: 3,
});
expect(result.assignedIp).toStartWith('10.8.0.');

View File

@@ -144,12 +144,17 @@ let keypair: IVpnKeypair;
let throttle: ThrottleProxy;
const allClients: VpnClient[] = [];
let clientCounter = 0;
async function createConnectedClient(port: number): Promise<VpnClient> {
clientCounter++;
const bundle = await server.createClient({ clientId: `load-client-${clientCounter}` });
const c = new VpnClient({ transport: { transport: 'stdio' } });
await c.start();
await c.connect({
serverUrl: `ws://127.0.0.1:${port}`,
serverPublicKey: keypair.publicKey,
clientPrivateKey: bundle.secrets.noisePrivateKey,
clientPublicKey: bundle.smartvpnConfig.clientPublicKey,
keepaliveIntervalSecs: 3,
});
allClients.push(c);

View File

@@ -2,7 +2,7 @@ import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as net from 'net';
import * as dgram from 'dgram';
import { VpnClient, VpnServer } from '../ts/index.js';
import type { IVpnClientOptions, IVpnServerOptions, IVpnKeypair, IVpnServerConfig } from '../ts/index.js';
import type { IVpnClientOptions, IVpnServerOptions, IVpnKeypair, IVpnServerConfig, IClientConfigBundle } from '../ts/index.js';
// ---------------------------------------------------------------------------
// Helpers
@@ -82,6 +82,8 @@ tap.test('setup: start VPN server in QUIC mode', async () => {
});
tap.test('QUIC client connects and gets IP', async () => {
const bundle = await server.createClient({ clientId: 'quic-client-1' });
const options: IVpnClientOptions = {
transport: { transport: 'stdio' },
};
@@ -92,6 +94,8 @@ tap.test('QUIC client connects and gets IP', async () => {
const result = await client.connect({
serverUrl: `127.0.0.1:${quicPort}`,
serverPublicKey: keypair.publicKey,
clientPrivateKey: bundle.secrets.noisePrivateKey,
clientPublicKey: bundle.smartvpnConfig.clientPublicKey,
transport: 'quic',
keepaliveIntervalSecs: 3,
});
@@ -162,12 +166,16 @@ tap.test('auto client connects to dual-mode server (QUIC preferred)', async () =
const started = await client.start();
expect(started).toBeTrue();
const bundle = await dualServer.createClient({ clientId: 'dual-auto-client' });
// "auto" mode (default): tries QUIC first at same host:port, falls back to WS
// Since the WS port and QUIC port differ, auto will try QUIC on WS port (fail),
// then fall back to WebSocket
const result = await client.connect({
serverUrl: `ws://127.0.0.1:${dualWsPort}`,
serverPublicKey: dualKeypair.publicKey,
clientPrivateKey: bundle.secrets.noisePrivateKey,
clientPublicKey: bundle.smartvpnConfig.clientPublicKey,
// transport defaults to 'auto'
keepaliveIntervalSecs: 3,
});
@@ -187,6 +195,8 @@ tap.test('auto client connects to dual-mode server (QUIC preferred)', async () =
});
tap.test('explicit QUIC client connects to dual-mode server', async () => {
const bundle = await dualServer.createClient({ clientId: 'dual-quic-client' });
const options: IVpnClientOptions = {
transport: { transport: 'stdio' },
};
@@ -197,6 +207,8 @@ tap.test('explicit QUIC client connects to dual-mode server', async () => {
const result = await client.connect({
serverUrl: `127.0.0.1:${dualQuicPort}`,
serverPublicKey: dualKeypair.publicKey,
clientPrivateKey: bundle.secrets.noisePrivateKey,
clientPublicKey: bundle.smartvpnConfig.clientPublicKey,
transport: 'quic',
keepaliveIntervalSecs: 3,
});
@@ -211,6 +223,8 @@ tap.test('explicit QUIC client connects to dual-mode server', async () => {
});
tap.test('keepalive exchange over QUIC', async () => {
const bundle = await dualServer.createClient({ clientId: 'dual-keepalive-client' });
const options: IVpnClientOptions = {
transport: { transport: 'stdio' },
};
@@ -220,6 +234,8 @@ tap.test('keepalive exchange over QUIC', async () => {
await client.connect({
serverUrl: `127.0.0.1:${dualQuicPort}`,
serverPublicKey: dualKeypair.publicKey,
clientPrivateKey: bundle.secrets.noisePrivateKey,
clientPublicKey: bundle.smartvpnConfig.clientPublicKey,
transport: 'quic',
keepaliveIntervalSecs: 3,
});

View File

@@ -2,10 +2,17 @@ import { tap, expect } from '@git.zone/tstest/tapbundle';
import { VpnConfig } from '../ts/index.js';
import type { IVpnClientConfig, IVpnServerConfig } from '../ts/index.js';
// Valid 32-byte base64 keys for testing
const TEST_KEY_A = 'YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE=';
const TEST_KEY_B = 'YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI=';
const TEST_KEY_C = 'Y2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2M=';
tap.test('VpnConfig: validate valid client config', async () => {
const config: IVpnClientConfig = {
serverUrl: 'wss://vpn.example.com/tunnel',
serverPublicKey: 'dGVzdHB1YmxpY2tleQ==',
serverPublicKey: TEST_KEY_A,
clientPrivateKey: TEST_KEY_B,
clientPublicKey: TEST_KEY_C,
dns: ['1.1.1.1', '8.8.8.8'],
mtu: 1420,
keepaliveIntervalSecs: 30,
@@ -16,7 +23,9 @@ tap.test('VpnConfig: validate valid client config', async () => {
tap.test('VpnConfig: reject client config without serverUrl', async () => {
const config = {
serverPublicKey: 'dGVzdHB1YmxpY2tleQ==',
serverPublicKey: TEST_KEY_A,
clientPrivateKey: TEST_KEY_B,
clientPublicKey: TEST_KEY_C,
} as IVpnClientConfig;
let threw = false;
try {
@@ -31,7 +40,9 @@ tap.test('VpnConfig: reject client config without serverUrl', async () => {
tap.test('VpnConfig: reject client config with invalid serverUrl scheme', async () => {
const config: IVpnClientConfig = {
serverUrl: 'http://vpn.example.com/tunnel',
serverPublicKey: 'dGVzdHB1YmxpY2tleQ==',
serverPublicKey: TEST_KEY_A,
clientPrivateKey: TEST_KEY_B,
clientPublicKey: TEST_KEY_C,
};
let threw = false;
try {
@@ -43,10 +54,28 @@ tap.test('VpnConfig: reject client config with invalid serverUrl scheme', async
expect(threw).toBeTrue();
});
tap.test('VpnConfig: reject client config without clientPrivateKey', async () => {
const config = {
serverUrl: 'wss://vpn.example.com/tunnel',
serverPublicKey: TEST_KEY_A,
clientPublicKey: TEST_KEY_C,
} as IVpnClientConfig;
let threw = false;
try {
VpnConfig.validateClientConfig(config);
} catch (e) {
threw = true;
expect((e as Error).message).toContain('clientPrivateKey');
}
expect(threw).toBeTrue();
});
tap.test('VpnConfig: reject client config with invalid MTU', async () => {
const config: IVpnClientConfig = {
serverUrl: 'wss://vpn.example.com/tunnel',
serverPublicKey: 'dGVzdHB1YmxpY2tleQ==',
serverPublicKey: TEST_KEY_A,
clientPrivateKey: TEST_KEY_B,
clientPublicKey: TEST_KEY_C,
mtu: 100,
};
let threw = false;
@@ -62,7 +91,9 @@ tap.test('VpnConfig: reject client config with invalid MTU', async () => {
tap.test('VpnConfig: reject client config with invalid DNS', async () => {
const config: IVpnClientConfig = {
serverUrl: 'wss://vpn.example.com/tunnel',
serverPublicKey: 'dGVzdHB1YmxpY2tleQ==',
serverPublicKey: TEST_KEY_A,
clientPrivateKey: TEST_KEY_B,
clientPublicKey: TEST_KEY_C,
dns: ['not-an-ip'],
};
let threw = false;
@@ -78,12 +109,15 @@ tap.test('VpnConfig: reject client config with invalid DNS', async () => {
tap.test('VpnConfig: validate valid server config', async () => {
const config: IVpnServerConfig = {
listenAddr: '0.0.0.0:443',
privateKey: 'dGVzdHByaXZhdGVrZXk=',
publicKey: 'dGVzdHB1YmxpY2tleQ==',
privateKey: TEST_KEY_A,
publicKey: TEST_KEY_B,
subnet: '10.8.0.0/24',
dns: ['1.1.1.1'],
mtu: 1420,
enableNat: true,
clients: [
{ clientId: 'test-client', publicKey: TEST_KEY_C },
],
};
// Should not throw
VpnConfig.validateServerConfig(config);
@@ -92,8 +126,8 @@ tap.test('VpnConfig: validate valid server config', async () => {
tap.test('VpnConfig: reject server config with invalid subnet', async () => {
const config: IVpnServerConfig = {
listenAddr: '0.0.0.0:443',
privateKey: 'dGVzdHByaXZhdGVrZXk=',
publicKey: 'dGVzdHB1YmxpY2tleQ==',
privateKey: TEST_KEY_A,
publicKey: TEST_KEY_B,
subnet: 'invalid',
};
let threw = false;
@@ -109,7 +143,7 @@ tap.test('VpnConfig: reject server config with invalid subnet', async () => {
tap.test('VpnConfig: reject server config without privateKey', async () => {
const config = {
listenAddr: '0.0.0.0:443',
publicKey: 'dGVzdHB1YmxpY2tleQ==',
publicKey: TEST_KEY_B,
subnet: '10.8.0.0/24',
} as IVpnServerConfig;
let threw = false;
@@ -122,4 +156,24 @@ tap.test('VpnConfig: reject server config without privateKey', async () => {
expect(threw).toBeTrue();
});
tap.test('VpnConfig: reject server config with invalid client publicKey', async () => {
const config: IVpnServerConfig = {
listenAddr: '0.0.0.0:443',
privateKey: TEST_KEY_A,
publicKey: TEST_KEY_B,
subnet: '10.8.0.0/24',
clients: [
{ clientId: 'bad-client', publicKey: 'short-key' },
],
};
let threw = false;
try {
VpnConfig.validateServerConfig(config);
} catch (e) {
threw = true;
expect((e as Error).message).toContain('publicKey');
}
expect(threw).toBeTrue();
});
export default tap.start();