16 KiB
Enterprise Auth & Client Management for SmartVPN
Context
SmartVPN's Noise NK mode currently allows any client that knows the server's public key to connect — no per-client identity or access control. The goal is to make SmartVPN enterprise-ready with:
- Per-client cryptographic authentication (Noise IK handshake)
- Rich client definitions with ACLs, rate limits, and priority
- Hub-generated configs — server generates typed SmartVPN client configs AND WireGuard .conf files from the same client definition
- Top-notch DX — one
createClient()call gives you everything
This is a breaking change. No backward compatibility with the old NK anonymous mode.
Design Overview
The Hub Model
The server acts as a hub that manages client definitions. Each client definition is the single source of truth from which both SmartVPN native configs and WireGuard configs are generated.
Hub (Server)
└── Client Registry
├── "alice-laptop" → SmartVPN config OR WireGuard .conf
├── "bob-phone" → SmartVPN config OR WireGuard .conf
└── "office-gw" → SmartVPN config OR WireGuard .conf
Authentication: NK → IK (Breaking Change)
Old (removed): Noise_NK_25519_ChaChaPoly_BLAKE2s — client is anonymous
New (always): Noise_IK_25519_ChaChaPoly_BLAKE2s — client presents its static key during handshake
IK is a 2-message handshake (same count as NK), so the frame protocol stays identical. Changes:
create_initiator()now requires(client_private_key, server_public_key)— alwayscreate_responder()remains(server_private_key)— but now uses IK pattern- After handshake, server extracts client's public key via
get_remote_static()and verifies against registry - Old NK functions are replaced, not kept alongside
Every client must have a keypair. Every server must have a client registry.
Core Interface: IClientEntry
This is the server-side client definition — the central config object:
export interface IClientEntry {
/** Human-readable client ID (e.g. "alice-laptop") */
clientId: string;
/** Client's Noise IK public key (base64) — for SmartVPN native transport */
publicKey: string;
/** Client's WireGuard public key (base64) — for WireGuard transport */
wgPublicKey?: string;
// ── Network ACLs ──────────────────────────────────────────────────────
/** Source IPs/CIDRs the client may connect FROM (empty = any) */
allowedFrom?: string[];
/** Destination IPs/CIDRs the client is allowed to reach (empty = all) */
allowedTo?: string[];
/** Blocklist: source IPs denied — overrides allowedFrom */
notAllowedFrom?: string[];
/** Blocklist: destination IPs denied — overrides allowedTo */
notAllowedTo?: string[];
// ── QoS ────────────────────────────────────────────────────────────────
/** Rate limit in bytes/sec (omit = server default or unlimited) */
rateLimitBytesPerSec?: number;
/** Burst size in bytes */
burstBytes?: number;
/** Traffic priority (lower = higher priority, default: 100) */
priority?: number;
// ── Metadata ───────────────────────────────────────────────────────────
/** Whether this client is enabled (default: true) */
enabled?: boolean;
/** Tags for grouping (e.g. ["engineering", "office"]) */
tags?: string[];
/** Optional description */
description?: string;
/** Optional expiry (ISO 8601 timestamp, omit = never expires) */
expiresAt?: string;
}
ACL Evaluation Order
1. Check notAllowedFrom / notAllowedTo first (explicit deny wins)
2. If denied, DROP
3. Check allowedFrom / allowedTo (explicit allow)
4. If allowedFrom is empty → allow any source
5. If allowedTo is empty → allow all destinations
Hub Config Generation
createClient() — The One-Call DX
When the hub creates a client, it:
- Generates a Noise IK keypair for the client
- Generates a WireGuard keypair for the client
- Allocates a VPN IP address
- Stores the
IClientEntryin the registry - Returns a complete config bundle with everything the client needs
export interface IClientConfigBundle {
/** The server-side client entry */
entry: IClientEntry;
/** Ready-to-use SmartVPN client config (typed object) */
smartvpnConfig: IVpnClientConfig;
/** Ready-to-use WireGuard .conf file content (string) */
wireguardConfig: string;
/** Client's private keys (ONLY returned at creation time, not stored server-side) */
secrets: {
noisePrivateKey: string;
wgPrivateKey: string;
};
}
The secrets are returned only at creation time — the server stores only public keys.
exportClientConfig() — Re-export (without secrets)
exportClientConfig(clientId: string, format: 'smartvpn' | 'wireguard'): IVpnClientConfig | string
Updated IVpnServerConfig
export interface IVpnServerConfig {
listenAddr: string;
tlsCert?: string;
tlsKey?: string;
privateKey: string; // Server's Noise static private key (base64)
publicKey: string; // Server's Noise static public key (base64)
subnet: string;
dns?: string[];
mtu?: number;
keepaliveIntervalSecs?: number;
enableNat?: boolean;
defaultRateLimitBytesPerSec?: number;
defaultBurstBytes?: number;
transportMode?: 'websocket' | 'quic' | 'both' | 'wireguard';
quicListenAddr?: string;
quicIdleTimeoutSecs?: number;
wgListenPort?: number;
wgPeers?: IWgPeerConfig[]; // Keep for raw WG mode
/** Pre-registered clients — REQUIRED for SmartVPN native transport */
clients: IClientEntry[];
}
Note: clients is now required (not optional), and there is no authMode field — IK is always used.
Updated IVpnClientConfig
export interface IVpnClientConfig {
serverUrl: string;
serverPublicKey: string;
/** Client's Noise IK private key (base64) — REQUIRED for SmartVPN native transport */
clientPrivateKey: string;
/** Client's Noise IK public key (base64) — for reference/display */
clientPublicKey: string;
dns?: string[];
mtu?: number;
keepaliveIntervalSecs?: number;
transport?: 'auto' | 'websocket' | 'quic' | 'wireguard';
serverCertHash?: string;
// WireGuard fields unchanged...
wgPrivateKey?: string;
wgAddress?: string;
wgAddressPrefix?: number;
wgPresharedKey?: string;
wgPersistentKeepalive?: number;
wgEndpoint?: string;
wgAllowedIps?: string[];
}
Note: clientPrivateKey and clientPublicKey are now required (not optional) for non-WireGuard transports.
New IPC Commands
Added to TVpnServerCommands:
| Command | Params | Result | Description |
|---|---|---|---|
createClient |
{ client: Partial<IClientEntry> } |
IClientConfigBundle |
Create client, generate keypairs, assign IP, return full config bundle |
removeClient |
{ clientId: string } |
void |
Remove from registry + disconnect if connected |
getClient |
{ clientId: string } |
IClientEntry |
Get a single client entry |
listRegisteredClients |
{} |
{ clients: IClientEntry[] } |
List all registered clients |
updateClient |
{ clientId: string, update: Partial<IClientEntry> } |
void |
Update ACLs, rate limits, tags, etc. |
enableClient |
{ clientId: string } |
void |
Enable a disabled client |
disableClient |
{ clientId: string } |
void |
Disable (but don't delete) |
rotateClientKey |
{ clientId: string } |
IClientConfigBundle |
New keypairs, return fresh config bundle |
exportClientConfig |
{ clientId: string, format: 'smartvpn' | 'wireguard' } |
{ config: string } |
Re-export config (without secrets) |
generateClientKeypair |
{} |
IVpnKeypair |
Generate a standalone Noise IK keypair |
Implementation Plan
Phase 1: Rust — Crypto (Replace NK with IK)
File: rust/src/crypto.rs
- Change
NOISE_PATTERNfrom NK to IK:"Noise_IK_25519_ChaChaPoly_BLAKE2s" - Replace
create_initiator(server_public_key)→create_initiator(client_private_key, server_public_key) create_responder(private_key)stays the same signature (IK responder only needs its own key)- After handshake,
get_remote_static()on the responder returns the client's public key - Update
perform_handshake()to pass client keypair - Update all tests
Phase 2: Rust — Client Registry module
New file: rust/src/client_registry.rs
Modify: rust/src/lib.rs — add pub mod client_registry;
pub struct ClientEntry {
pub client_id: String,
pub public_key: String,
pub wg_public_key: Option<String>,
pub allowed_from: Option<Vec<String>>,
pub allowed_to: Option<Vec<String>>,
pub not_allowed_from: Option<Vec<String>>,
pub not_allowed_to: Option<Vec<String>>,
pub rate_limit_bytes_per_sec: Option<u64>,
pub burst_bytes: Option<u64>,
pub priority: Option<u32>,
pub enabled: Option<bool>,
pub tags: Option<Vec<String>>,
pub description: Option<String>,
pub expires_at: Option<String>,
pub assigned_ip: Option<String>,
}
pub struct ClientRegistry {
entries: HashMap<String, ClientEntry>, // keyed by clientId
key_index: HashMap<String, String>, // publicKey → clientId (fast lookup)
}
Methods: add, remove, get_by_id, get_by_key, update, list, is_authorized (enabled + not expired + key exists), rotate_key.
Phase 3: Rust — ACL enforcement module
New file: rust/src/acl.rs
Modify: rust/src/lib.rs — add pub mod acl;
pub fn check_acl(entry: &ClientEntry, src_ip: Ipv4Addr, dst_ip: Ipv4Addr) -> AclResult {
// 1. Check notAllowedFrom/notAllowedTo (deny overrides)
// 2. Check allowedFrom/allowedTo (explicit allow)
// 3. Empty list = allow all
}
Called in server.rs packet loop after decryption, before forwarding.
Phase 4: Rust — Server changes
File: rust/src/server.rs
- Add
clients: Option<Vec<ClientEntry>>toServerConfig - Add
client_registry: RwLock<ClientRegistry>toServerState(noauth_mode— always IK) - Modify
handle_client_connection():- Always use
create_responder()(now IK pattern) - Call
get_remote_static()beforeinto_transport_mode()to get client's public key - Verify against registry — reject unauthorized clients with Disconnect frame
- Use registry entry for rate limits (overrides server defaults)
- In packet loop: call
acl::check_acl()on decrypted packets
- Always use
- Add
ClientInfo.authenticated_key: StringandClientInfo.registered_client_id: String(no longer optional) - Add methods:
create_client(),remove_client(),update_client(),list_registered_clients(),rotate_client_key(),export_client_config()
Phase 5: Rust — Client changes
File: rust/src/client.rs
- Add
client_private_key: StringtoClientConfig(required, not optional) connect()always usescreate_initiator(client_private_key, server_public_key)(IK)
Phase 6: Rust — Management IPC handlers
File: rust/src/management.rs
Add handlers for all 10 new IPC commands following existing patterns.
Phase 7: TypeScript — Interfaces
File: ts/smartvpn.interfaces.ts
- Add
IClientEntryinterface - Add
IClientConfigBundleinterface - Update
IVpnServerConfig: add requiredclients: IClientEntry[] - Update
IVpnClientConfig: add requiredclientPrivateKey: string,clientPublicKey: string - Update
IVpnClientInfo: addauthenticatedKey: string,registeredClientId: string - Add new commands to
TVpnServerCommands
Phase 8: TypeScript — VpnServer class methods
File: ts/smartvpn.classes.vpnserver.ts
Add methods:
createClient(opts)→IClientConfigBundleremoveClient(clientId)→voidgetClient(clientId)→IClientEntrylistRegisteredClients()→IClientEntry[]updateClient(clientId, update)→voidenableClient(clientId)/disableClient(clientId)rotateClientKey(clientId)→IClientConfigBundleexportClientConfig(clientId, format)→string | IVpnClientConfig
Phase 9: TypeScript — Config validation
File: ts/smartvpn.classes.vpnconfig.ts
- Server config: validate
clientspresent, each entry has validclientId+publicKey - Client config: validate
clientPrivateKeyandclientPublicKeypresent for non-WG transports - Validate CIDRs in ACL fields
Phase 10: TypeScript — Hub config generation
File: ts/smartvpn.classes.wgconfig.ts (extend existing)
Add generateClientConfigFromEntry(entry, serverConfig) — produces WireGuard .conf from IClientEntry.
Phase 11: Update existing tests
All existing tests that use the old NK handshake or old config shapes need updating:
- Rust tests in
crypto.rs,server.rs,client.rs - TS tests in
test/test.vpnconfig.node.ts,test/test.flowcontrol.node.ts, etc. - Tests now must provide client keypairs and client registry entries
DX Highlights
-
One call to create a client:
const bundle = await server.createClient({ clientId: 'alice-laptop', tags: ['engineering'] }); // bundle.smartvpnConfig — typed SmartVPN client config // bundle.wireguardConfig — standard WireGuard .conf string // bundle.secrets — private keys, shown only at creation time -
Typed config objects throughout — no raw strings or JSON blobs
-
Dual transport from same definition — register once, connect via SmartVPN or WireGuard
-
ACLs are deny-overrides-allow — intuitive enterprise model
-
Hot management — add/remove/update/disable clients at runtime
-
Key rotation —
rotateClientKey()generates new keys and returns a fresh config bundle
Verification Plan
-
Rust unit tests:
crypto.rs: IK handshake roundtrip,get_remote_static()returns correct key, wrong key failsclient_registry.rs: CRUD,is_authorizedwith enabled/disabled/expiredacl.rs: allow/deny logic, empty lists, deny-overrides-allow
-
Rust integration tests:
- Server accepts authorized client
- Server rejects unknown client public key
- ACL filtering drops packets to blocked destinations
- Runtime
createClient/removeClientworks - Disabled client rejected at handshake
-
TypeScript tests:
- Config validation with required client fields
createClient()returns valid bundle with both formatsexportClientConfig()generates valid WireGuard .conf- Full IPC roundtrip: create client → connect → traffic → disconnect
-
Build:
pnpm build(TS + Rust),cargo test,pnpm test
Key Files to Modify
| File | Changes |
|---|---|
rust/src/crypto.rs |
Replace NK with IK pattern, update initiator signature |
rust/src/client_registry.rs |
NEW — client registry module |
rust/src/acl.rs |
NEW — ACL evaluation module |
rust/src/server.rs |
Registry integration, IK auth in handshake, ACL in packet loop |
rust/src/client.rs |
Required client_private_key, IK initiator |
rust/src/management.rs |
10 new IPC command handlers |
rust/src/lib.rs |
Register new modules |
ts/smartvpn.interfaces.ts |
IClientEntry, IClientConfigBundle, updated configs & commands |
ts/smartvpn.classes.vpnserver.ts |
New hub methods |
ts/smartvpn.classes.vpnconfig.ts |
Updated validation rules |
ts/smartvpn.classes.wgconfig.ts |
Config generation from client entries |