diff --git a/.gitignore b/.gitignore index 78346b0..d651fb5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ *.swp *.swo .DS_Store + +# Runtime config (rendered at deploy time, may contain secrets) +config.json diff --git a/CLAUDE.md b/CLAUDE.md index 4a6f8e9..d863ad4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,78 +1,142 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -OpenNHP JavaScript Agent - A JavaScript implementation of the NHP (Network-infrastructure Hiding Protocol) for zero-trust network security. The project demonstrates NHP authentication flows and connection status visualization. - -**Live Demo:** https://js-agent.opennhp.org - -## Project Structure - -The repository has two main components: - -1. **Root-level demo** (`/index.html`) - Static HTML demo page with i18n support (EN/ZH/ES) that demonstrates the NHP authentication flow via Okta integration. Deployed directly to GitHub Pages. - -2. **NHP-JS library** (`/nhp-js/`) - The core NHP protocol implementation as a Vite-bundled JavaScript library. - -## Build Commands - -All commands must be run from the `/nhp-js` directory: - -```bash -cd nhp-js - -# Install dependencies -npm install - -# Development server with hot reload -npm run dev - -# Build the library (outputs to nhp-js/dist/) -npm run build - -# Preview the production build -npm run preview -``` - -The build produces `nhp-js/dist/nhp-js-lib.js` which is imported by `main.js` for testing. - -## Architecture - -### NHP Protocol Library (`nhp-js/src/`) - -- **nhp.js** - Main entry point. Implements NHP packet building and parsing: - - `buildNHPPacket()` - Constructs encrypted NHP packets with X25519 key exchange - - `parseNHPPacket()` - Decrypts and validates incoming NHP packets - - `NHPHeader` / `NHPHeaderEx` - Binary packet header structures (240 / 304 bytes) - - Packet types: KNK (1), ACK (2), COK (7), RNK (8) - -- **crypto.js** - Cryptographic primitives: - - X25519 key generation and ECDH via Web Crypto API - - PKCS#8 encoding/decoding for private keys - - SHA-256 hashing and HMAC via CryptoJS - - ChaCha20-Poly1305 AEAD encryption via @noble/ciphers - -- **crypto_gm.js** - Chinese GM/T cryptographic standards (SM2) - currently disabled - -- **utils.js** - Utility functions: base64 encoding, zlib compression via CompressionStream API - -### Key Dependencies - -- `@noble/ciphers` - ChaCha20-Poly1305 AEAD -- `@noble/curves` - Elliptic curve operations -- `crypto-js` - SHA-256 and HMAC -- `gm-crypto` - SM2/SM3/SM4 (Chinese cryptography standards) - -### Demo Page (`/index.html`) - -Static single-file demo with: -- Tailwind CSS (CDN) -- Built-in i18n system with translations object -- Okta OAuth integration for authentication -- Connection to `acdemo.opennhp.org` protected server - -## Deployment - -The root `index.html` is deployed via GitHub Pages to `js-agent.opennhp.org`. The CNAME file configures the custom domain. +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +OpenNHP JavaScript Agent — A TypeScript/JavaScript SDK (`@opennhp/agent`) implementing the NHP (Network-infrastructure Hiding Protocol) for zero-trust network security, plus a static demo page. + +**Live Demo:** https://js-agent.opennhp.org + +## Repository Structure + +Two independent components: + +1. **`/index.html`** — Static demo page with i18n (EN/ZH/ES) and Okta OAuth integration. Deployed to GitHub Pages at `js-agent.opennhp.org`. + +2. **`/nhp-js/`** — Core SDK library. TypeScript source in `src/`, built with Vite into `dist/`. + +## Build & Dev Commands + +All commands run from `/nhp-js/`: + +```bash +cd nhp-js +npm install + +npm run dev # Vite dev server (http://localhost:5173), loads main.js +npm run build # Build to dist/ (ES + CJS + .d.ts) +npm run preview # Preview production build + +npm test # Vitest watch mode +npm run test:run # Run tests once +npm run test:coverage # Coverage report in coverage/ + +npm run lint # ESLint +npm run lint:fix # Auto-fix +npm run format # Prettier +npm run docs # TypeDoc → docs/ +``` + +Build outputs: `dist/index.js` (ESM), `dist/index.cjs` (CJS), `dist/index.d.ts` (types). + +## Architecture + +### Source layout (`nhp-js/src/`) + +```text +src/ +├── index.ts # Public API exports +├── NHPAgent.ts # High-level agent class (main SDK entry point) +├── types.ts # All TypeScript interfaces and enums +├── crypto/ +│ ├── index.ts # Re-exports key crypto utilities +│ ├── ecdh.ts # X25519 key generation and ECDH +│ ├── aead.ts # AES-256-GCM encrypt/decrypt +│ ├── noise.ts # Blake2s hash, HMAC, HKDF-like key derivation (Noise protocol) +│ ├── sm2.ts # SM2 elliptic curve key generation and ECDH (GMSM) +│ ├── sm3.ts # SM3 hash and HMAC (GMSM) +│ ├── sm4.ts # SM4-GCM encrypt/decrypt (GMSM) +│ └── utils.ts # base64, hex, zlib compress/decompress, getUnixNano +├── protocol/ +│ ├── index.ts # Re-exports +│ ├── constants.ts # Packet types, header sizes, offsets, field sizes +│ ├── header.ts # NHPHeader (240 B, curve25519) / NHPHeaderEx (304 B, gmsm) +│ └── packet.ts # buildNHPPacket(), parseNHPPacket(), clearServerCookie() +└── transport/ + ├── index.ts # Re-exports + ├── udp.ts # UDP transport (Node.js default) + ├── webrtc.ts # WebRTC DataChannel transport (experimental) + ├── websocket.ts # WebSocket transport (legacy) + └── relay.ts # HTTP Relay transport (browser → relay → NHP Server) +``` + +### Key classes and functions + +**`NHPAgent`** (`NHPAgent.ts`) — High-level SDK class: + +- `init()` — generates or loads key pair +- `setIdentity({ userId, deviceId, organizationId })` — set knock identity +- `addServer({ publicKey, host, port })` — register an NHP server +- `knockResource({ resourceId, serviceId, serverHost, serverPort })` — returns `KnockResult` +- `close()` — disconnect and cleanup +- Transport auto-selected: `relay` in browser (requires `relayUrl`), `udp` in Node.js + +**`buildNHPPacket(type, privKey, pubKey, remotePubKey, message, compress, cipherScheme)`** — builds binary NHP packet. Both keys and remote key are base64 strings. + +**`parseNHPPacket(packet, privKey, pubKey, remotePubKey)`** — decrypts and validates an ACK or COK packet. Throws on HMAC failure, wrong size, replay, or stale timestamp. + +### Protocol + +4-step handshake (Noise-protocol-inspired): + +1. Agent → Server: **KNK** (knock with identity + resource) +2. Server → Agent: **COK** (optional, server overloaded — contains cookie) +3. Agent → Server: **RNK** (re-knock with cookie in HMAC) +4. Server → Agent: **ACK** (access granted, contains resource hosts + expiry) + +### Cipher schemes + +| Scheme | Key Exchange | Hash | AEAD | Header | +| ------ | ------------ | ---- | ---- | ------ | +| `curve25519` (default) | X25519 ECDH | Blake2s-256 | AES-256-GCM | 240 bytes | +| `gmsm` | SM2 ECDH | SM3 | SM4-GCM | 304 bytes | + +Auto-detection: if public key base64 length > 50 chars → `gmsm`, otherwise `curve25519`. + +**Important:** `parseNHPPacket` routes to GMSM parser by reading the `extended` flag bit at header offset 10–11 (big-endian uint16, bit 0x1). Do not change this to little-endian. + +### Key dependencies + +- `@noble/ciphers` — AES-256-GCM +- `@noble/curves` — X25519 +- `@noble/hashes` — Blake2s +- `sm-crypto-v2` — SM2/SM3/SM4 + +### Test layout (`nhp-js/test/`) + +```text +test/ +├── crypto/ +│ ├── aead.test.ts # AES-256-GCM round-trip and error cases +│ ├── ecdh.test.ts # X25519 key gen, ECDH, Go test vectors +│ ├── gmsm.test.ts # SM2/SM3/SM4 operations +│ └── noise.test.ts # Blake2s, HMAC, key derivation +└── protocol/ + ├── header.test.ts # NHPHeader / NHPHeaderEx field read-write + └── packet.test.ts # buildNHPPacket/parseNHPPacket round-trips, + # COK/RNK flow, error cases, anti-replay, GMSM +``` + +### Known issues / gotchas + +- **UDP and zlib** use Node.js built-ins (`dgram`, `zlib`). Vite externalizes them for browser builds — this is expected. Browser zlib uses `CompressionStream` API (see `utils.ts`). +- **`main.js`** is a manual debug entry point for the Vite dev server, not a production file. It is excluded from ESLint. +- **`resetGlobalCounter()`** is exported for test isolation — call it in `beforeEach` when writing packet tests to avoid counter-dependent failures. It also clears the internal `lastBuildChainKeyMap`. +- **Noise chain key between KNK and ACK is all-zeros.** Both Go server (`decryptBody` defer) and Go agent (`encryptBody` defer) clear `chainKey` after processing. The JS agent passes `new Uint8Array(32)` as `prevChainKey` when parsing ACK responses to match this behavior. Do not change this without updating both Go and JS sides. +- **Relay transport response matching** uses the inner packet's counter (header bytes `[16:24]`, big-endian uint64) to correlate HTTP requests with UDP responses. The relay does not decrypt ACK/COK packets — it forwards raw encrypted bytes. +- **`errCode: "0"` means success** in the NHP protocol. The `parseAckResponse` method treats `"0"` and `""` as success; any other value is an error. + +## Deployment + +`/index.html` auto-deploys to `js-agent.opennhp.org` via GitHub Pages on push to `main`. CI (`.github/workflows/ci.yml`) runs build + tests on Node.js 18, 20, 22 for every push and PR. diff --git a/README.md b/README.md index 51e7b07..a35ca95 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,827 @@ # OpenNHP JavaScript Agent -The Javascript version of NHP-Agent and its usage demonstration. +The JavaScript/TypeScript implementation of the NHP (Network-infrastructure Hiding Protocol) agent and its usage demonstration. -🌐 **Live Demo:** [https://js-agent.opennhp.org](https://js-agent.opennhp.org) +[![CI](https://github.com/OpenNHP/js-agent/actions/workflows/ci.yml/badge.svg)](https://github.com/OpenNHP/js-agent/actions/workflows/ci.yml) +[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) + +**Live Demo:** [https://js-agent.opennhp.org](https://js-agent.opennhp.org) ## Overview -This demo showcases the OpenNHP authentication flow and connection status visualization. OpenNHP is a zero-trust network security protocol that hides protected servers from unauthorized access. +OpenNHP is a zero-trust network security protocol that hides protected servers from unauthorized access. This repository contains: + +1. **`/nhp-js/`** — Core SDK library (`@opennhp/agent`) implementing the NHP protocol in TypeScript, supporting both browser and Node.js environments. +2. **`/index.html`** — Static demo page showcasing the NHP authentication flow via Okta integration, deployed to GitHub Pages. + +## Repository Structure -### Deployment +```text +js-agent/ +├── index.html # Demo page (deployed to js-agent.opennhp.org) +├── CNAME # Custom domain for GitHub Pages +├── nhp-js/ # Core SDK library +│ ├── src/ +│ │ ├── NHPAgent.ts # Main agent class +│ │ ├── types.ts # TypeScript interfaces and types +│ │ ├── index.ts # Public API exports +│ │ ├── crypto/ # Cryptographic primitives +│ │ │ ├── ecdh.ts # X25519 key exchange +│ │ │ ├── aead.ts # AES-256-GCM encryption +│ │ │ ├── noise.ts # Noise protocol (Blake2s) +│ │ │ ├── sm2.ts # SM2 elliptic curve (GMSM) +│ │ │ ├── sm3.ts # SM3 hash (GMSM) +│ │ │ ├── sm4.ts # SM4-GCM encryption (GMSM) +│ │ │ └── utils.ts # Utility functions (base64, zlib, etc.) +│ │ ├── protocol/ # NHP packet format +│ │ │ ├── constants.ts # Protocol constants and packet types +│ │ │ ├── header.ts # Packet header structures (240/304 bytes) +│ │ │ └── packet.ts # Packet build/parse logic +│ │ └── transport/ # Network transports +│ │ ├── udp.ts # UDP (Node.js default) +│ │ ├── relay.ts # HTTP Relay (browser default) +│ │ ├── websocket.ts # WebSocket (alternative) +│ │ └── webrtc.ts # WebRTC DataChannel (experimental) +│ ├── test/ # Unit tests (Vitest) +│ ├── examples/ # Usage examples +│ │ └── relay-test.html # HTTP Relay transport test page +│ ├── main.js # Manual test entry point +│ ├── vite.config.ts # Build configuration +│ ├── tsconfig.json # TypeScript configuration +│ └── package.json +└── .github/workflows/ + └── ci.yml # GitHub Actions: build + test on Node 18/20/22 +``` -This project is deployed via GitHub Pages. Any push to the `main` branch will automatically deploy to [js-agent.opennhp.org](https://js-agent.opennhp.org). +## SDK Quick Start -## Project Structure +### Installation +```bash +npm install @opennhp/agent ``` -js-agent/ -├── index.html # Main application file -├── CNAME # Custom domain configuration -├── LICENSE # License file -└── README.md # This file + +### Basic Usage (HTTP Relay — Browser) + +The SDK uses an HTTP relay transport for browser environments. The relay is a stateless HTTPS→UDP bridge — the browser POSTs the encrypted NHP packet to the relay, which wraps it in a Noise-encrypted NHP-RLY outer packet and forwards it to the NHP Server over UDP. + +```typescript +import { NHPAgent } from '@opennhp/agent'; + +const agent = new NHPAgent({ + cipherScheme: 'gmsm', // or 'curve25519' + transport: 'relay', + relayUrl: 'https://relay.example.com/relay', + logLevel: 'info', +}); + +await agent.init(); + +agent.setIdentity({ + userId: 'user@example.com', + deviceId: 'my-device-id', + organizationId: 'example.org', +}); + +agent.addServer({ + publicKey: 'BASE64_SERVER_PUBLIC_KEY', +}); + +const result = await agent.knockResource({ + resourceId: 'protected-resource', + serviceId: 'my-service', +}); + +if (result.success) { + console.log('Access granted until:', new Date(result.expiresAt!)); + console.log('Resource hosts:', result.resourceHosts); +} + +await agent.close(); +``` + +**Prerequisites:** + +- The agent's public key must be registered in the NHP Server's `etc/agent.toml` +- The relay's public key must be registered in the NHP Server's `etc/relay.toml` + +### Basic Usage (UDP — Node.js) + +```typescript +import { NHPAgent } from '@opennhp/agent'; + +const agent = new NHPAgent({ + cipherScheme: 'gmsm', + logLevel: 'info', +}); + +await agent.init(); + +agent.setIdentity({ + userId: 'user@example.com', + deviceId: 'my-device-id', + organizationId: 'example.org', +}); + +agent.addServer({ + publicKey: 'BASE64_SERVER_PUBLIC_KEY', + host: 'nhp.example.com', + port: 62206, +}); + +const result = await agent.knockResource({ + resourceId: 'protected-resource', + serviceId: 'my-service', + serverHost: 'nhp.example.com', + serverPort: 62206, +}); + +if (result.success) { + console.log('Access granted until:', new Date(result.expiresAt!)); + console.log('Resource hosts:', result.resourceHosts); +} + +await agent.close(); +``` + +## Transport Selection + +| Environment | Default | Also Available | +| ----------- | ------- | -------------- | +| Browser | HTTP Relay | WebSocket | +| Node.js | UDP | WebSocket | + +Override with `new NHPAgent({ transport: 'relay', relayUrl: '...' })`. + +## Protocol Overview + +### Cipher Schemes + +Two cipher schemes are supported: + +| Scheme | Key Exchange | Hash | AEAD | Header Size | +| ------ | ------------ | ---- | ---- | ----------- | +| `curve25519` (default) | X25519 ECDH | Blake2s-256 | AES-256-GCM | 240 bytes | +| `gmsm` | SM2 ECDH | SM3 | SM4-GCM | 304 bytes | + +### Noise-Protocol-Inspired Handshake + +NHP uses a 4-step handshake: + +1. **Agent → Server (KNK)**: Agent sends encrypted knock with identity and resource request, using ephemeral ECDH + server's long-term public key. +2. **Server → Agent (COK)**: If overloaded, server replies with a cookie. +3. **Agent → Server (RNK)**: Agent re-knocks with the cookie included in HMAC. +4. **Server → Agent (ACK)**: Server grants access with resource host mapping and expiry time. + +### System Architecture + +```mermaid +flowchart TD + subgraph Browser["Browser Environment"] + UI["Demo Page / Application"] + Agent["NHPAgent SDK\n(@opennhp/agent)"] + T_RL["HTTP Relay Transport"] + end + + subgraph Relay["NHP Relay Service (nhp-relay)"] + RelayHTTP["HTTP Endpoint\nPOST /relay"] + RelayNoise["Noise Encrypt\nWrap inner packet in NHP-RLY"] + RelayUDP["UDP Forward\nSend to NHP Server"] + end + + subgraph NodeJS["Node.js Environment"] + AgentNode["NHPAgent SDK"] + T_UDP["UDP Transport"] + end + + subgraph NHPServer["NHP Server"] + UDPRecv["UDP Listener\n:62206"] + RLYHandler["NHP-RLY Handler\nDecrypt outer → extract inner"] + NHPProto["NHP Protocol Handler\nDecrypt KNK → verify → authorize"] + Policy["Access Policy"] + end + + subgraph AC["NHP Access Controller"] + ACRecv["NHP-AOP Handler\nOpen firewall rules"] + end + + subgraph Protected["Protected Resource"] + App["Application Server\n(hidden by default)"] + end + + %% Relay path (browser) + Agent --> T_RL + T_RL -->|"POST /relay\n(raw KNK bytes)"| RelayHTTP + RelayHTTP --> RelayNoise + RelayNoise -->|"NHP-RLY packet\n(Noise-encrypted)"| RelayUDP + RelayUDP -->|"UDP"| UDPRecv + UDPRecv --> RLYHandler + RLYHandler -->|"inner KNK packet"| NHPProto + + %% Node.js UDP path + AgentNode --> T_UDP + T_UDP -->|"KNK packet\n(UDP)"| UDPRecv + UDPRecv --> NHPProto + + %% Server → AC + NHPProto --> Policy + Policy -->|"authorized"| NHPProto + NHPProto -->|"NHP-AOP\n(open request)"| ACRecv + ACRecv -->|"NHP-ART\n(result)"| NHPProto + ACRecv -->|"ipset add\nclient IP → port"| App + + %% ACK return paths + NHPProto -->|"ACK packet"| UDPRecv + UDPRecv -->|"ACK (UDP)"| T_UDP + UDPRecv -->|"ACK (UDP → Relay)"| RelayUDP + RelayUDP -->|"raw ACK bytes\n(HTTP 200)"| T_RL +``` + +### HTTP Relay Transport Sequence (Browser) + +The relay transport is the primary browser transport. The relay is a Go service that bridges HTTPS to UDP. + +```mermaid +sequenceDiagram + participant User as User + participant Page as Test Page + participant Agent as NHPAgent SDK
(Browser) + participant Relay as NHP Relay
(nhp-relay) + participant Server as NHP Server + participant AC as NHP AC
(Access Controller) + participant Res as Protected Resource + + Note over Res: All ports hidden by default + Note over Relay: Stateless HTTPS→UDP bridge
Has its own SM2 key pair
Registered as NHP_RELAY peer on Server + + User->>Page: Fill config & click Knock + Page->>Agent: new NHPAgent({ transport:'relay', relayUrl }) + Agent->>Agent: init() — generate/load SM2 key pair + + Page->>Agent: setIdentity, addServer, knockResource + + rect rgb(40, 60, 90) + Note over Agent: Step 1: Agent builds KNK packet (413B)
(encrypted to NHP Server's public key) + Agent->>Agent: buildNHPPacket(KNK, agentPrivKey, agentPubKey,
serverPubKey, knockMsg, compress=true, cipher='gmsm') + Note over Agent: 304B GMSM header + zlib-compressed
SM4-GCM encrypted payload + end + + rect rgb(60, 40, 90) + Note over Agent,Relay: Step 2: Browser POSTs raw KNK bytes to Relay + Agent->>Relay: POST /relay
Content-Type: application/octet-stream
Body: raw KNK packet (413 bytes) + Note over Relay: Relay extracts inner counter from
header bytes [16:24] for response matching + end + + rect rgb(90, 60, 40) + Note over Relay: Step 3: Relay wraps KNK in NHP-RLY packet + Relay->>Relay: Build RelayForwardMsg JSON:
{ srcAddr: {ip, port}, innerPkt: base64(KNK) } + Relay->>Relay: Noise-encrypt with Relay ↔ Server keys + Note over Relay: NHP-RLY outer packet (932B)
Relay authenticates itself to Server + end + + rect rgb(40, 90, 60) + Note over Relay,Server: Step 4: Relay sends NHP-RLY over UDP + Relay->>Server: UDP NHP-RLY packet (932 bytes) + end + + rect rgb(60, 90, 40) + Note over Server: Step 5: Server decrypts NHP-RLY outer packet + Server->>Server: Verify Relay HMAC (SM3) + Server->>Server: SM2_ECDH → decrypt RelayForwardMsg + Server->>Server: Lookup Relay pubkey in relay peer pool + Server->>Server: Extract real client IP + inner KNK bytes + end + + rect rgb(90, 40, 60) + Note over Server: Step 6: Server decrypts inner KNK packet + Server->>Server: Verify Agent HMAC (SM3) + Server->>Server: SM2_ECDH(serverPriv, agentEphPub) + Server->>Server: SM4-GCM decrypt Agent pubkey + Server->>Server: Lookup Agent pubkey in agent peer pool + Server->>Server: SM4-GCM decrypt timestamp → anti-replay + Server->>Server: SM4-GCM decrypt body → decompress
→ {usrId, devId, orgId, aspId, resId} + Server->>Server: Auth plugin verifies identity + end + + rect rgb(40, 90, 90) + Note over Server,AC: Step 7: Server requests AC to open firewall + Server->>AC: NHP-AOP (490B, UDP)
srcAddrs: [browser IP, gateway IP]
dstAddrs: [resource IP:port]
opnTime: 20s + AC->>AC: ipset add browser_IP,80,resource_IP timeout 20 + AC-->>Server: NHP-ART (429B)
{ errCode: "0", opnTime: 20, token: "..." } + end + + alt Server overloaded + Server-->>Relay: COK packet (UDP, encrypted to Agent) + Relay-->>Agent: HTTP 200 (raw COK bytes) + Agent->>Agent: Store cookie + Agent->>Relay: POST /relay (RNK packet with cookie) + Relay->>Server: UDP NHP-RLY (wrapping RNK) + end + + rect rgb(40, 60, 90) + Note over Server: Step 8: Server builds ACK response (493B) + Server->>Server: Noise-encrypt ACK to Agent's pubkey
{ errCode:"0", resHost, opnTime, acTokens } + Note over Server: ACK encrypted to Agent — opaque to Relay
Sent via relay conn (RemoteAddr = relay UDP addr) + end + + Server-->>Relay: UDP ACK packet (493 bytes) + Note over Relay: Match by inner counter → forward raw bytes + Relay-->>Agent: HTTP 200 (raw ACK bytes, 493B) + + Agent->>Agent: parseNHPPacket(ACK, prevChainKey=zeros)
SM3 HMAC verify → SM2 ECDH →
SM4-GCM decrypt → zlib decompress + Agent-->>Page: KnockResult { success, resourceHosts, expiresAt } + Page-->>User: Access Granted + + Note over Res: AC opened firewall for browser IP
(timeout 20s, renewable) + User->>Res: Access resource directly +``` + +### Encryption Layer Details — Relay Mode + +The relay transport involves **two independent encryption layers**: + +```text +┌─────────────────────────────────────────────────────────────────┐ +│ NHP-RLY Outer Packet (932B) │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ NHP Header (304B, GMSM) │ │ +│ │ • Type: NHP_RLY (9) │ │ +│ │ • Ephemeral: Relay's ephemeral SM2 public key │ │ +│ │ • Static: Relay's device public key (SM4-GCM encrypted) │ │ +│ │ • HMAC: SM3 hash │ │ +│ │ • Encrypted with: Relay ↔ Server shared secret │ │ +│ └───────────────────────────────────────────────────────────┘ │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ Encrypted Payload (RelayForwardMsg JSON, zlib+SM4-GCM) │ │ +│ │ { │ │ +│ │ "srcAddr": { "ip": "192.168.1.100", "port": 54321 }, │ │ +│ │ "innerPkt": "base64(raw KNK packet bytes)" │ │ +│ │ } │ │ +│ └───────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ │ +│ Inner KNK Packet (413B) — OPAQUE to Relay │ +│ │ ┌─────────────────────────────────────────────────────┐ │ │ +│ │ NHP Header (304B, GMSM) │ │ +│ │ │ • Type: NHP_KNK (1) │ │ │ +│ │ • Ephemeral: Agent's ephemeral SM2 public key │ │ +│ │ │ • Static: Agent's device public key (encrypted) │ │ │ +│ │ • HMAC: SM3 hash │ │ +│ │ │ • Encrypted with: Agent ↔ Server shared secret │ │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ ┌─────────────────────────────────────────────────────┐ │ │ +│ │ Encrypted Payload (AgentKnockMsg JSON, zlib+SM4-GCM)│ │ +│ │ │ { usrId, devId, orgId, aspId, resId } │ │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**Key principle: the Relay never sees the plaintext KNK payload.** The inner KNK packet is encrypted end-to-end between the Agent and the NHP Server. The Relay only sees it as opaque bytes embedded in the `innerPkt` field. + +### Verified End-to-End Relay Flow (GMSM cipher scheme) + +The following is the complete verified data flow for Browser -> Relay -> NHP Server -> AC, with actual packet sizes observed during testing: + +```text +Browser (JS Agent) NHP Relay NHP Server NHP AC +────────────────── ───────── ────────── ────── + +1. Agent.init() + Generate SM2 key pair + (privKey: 32B, pubKey: 64B) + +2. buildNHPPacket(KNK) + ┌──────────────────────────┐ + │ Noise-IK handshake: │ + │ • ChainHash = SM3(init) │ + │ • Generate ephemeral SM2 │ + │ • ESS = SM2_ECDH( │ + │ ephPriv, serverPub) │ + │ • Encrypt agentPub │ + │ via SM4-GCM │ + │ • SS = SM2_ECDH( │ + │ agentPriv, serverPub)│ + │ • Encrypt timestamp │ + │ • Encrypt body (zlib │ + │ compressed JSON) │ + │ • HMAC = SM3(header) │ + │ Output: 413 bytes │ + └──────────────────────────┘ + │ +3. POST /relay │ 413 bytes + (raw binary) │ + ▼ + Relay receives HTTP POST + ┌──────────────────────────┐ + │ Extract inner counter │ + │ from bytes [16:24] │ + │ Register pending request │ + │ pendingRequests[ctr] │ + │ │ + │ Build RelayForwardMsg: │ + │ { srcAddr: {ip, port}, │ + │ innerPkt: base64(KNK) }│ + │ │ + │ buildNHPPacket(NHP_RLY) │ + │ Noise encrypt with │ + │ Relay ↔ Server keys │ + │ Output: 932 bytes │ + └──────────────────────────┘ + │ + UDP send │ 932 bytes + ▼ + Server receives NHP-RLY + ┌──────────────────────────┐ + │ Decrypt outer NHP-RLY: │ + │ • Verify Relay HMAC │ + │ • SM2_ECDH(serverPriv, │ + │ relayEphPub) │ + │ • Decrypt Relay pubkey │ + │ • Lookup Relay in │ + │ relay peer pool │ + │ • Decrypt body → │ + │ RelayForwardMsg │ + │ │ + │ HandleRelayForward: │ + │ • Extract realClientAddr │ + │ • Decode inner KNK │ + │ • Create relay conn │ + │ (key: relay:addr:cli) │ + │ RemoteAddr = relayAddr │ + │ RealRemoteAddr = cli │ + │ • Inject KNK into conn │ + └──────────────────────────┘ + ┌──────────────────────────┐ + │ Decrypt inner NHP-KNK: │ + │ • Verify Agent HMAC │ + │ • SM2_ECDH(serverPriv, │ + │ agentEphPub) │ + │ • Decrypt Agent pubkey │ + │ • Lookup Agent in │ + │ agent peer pool │ + │ • Decrypt timestamp │ + │ • Anti-replay check │ + │ • Decrypt body → │ + │ { usrId, devId, orgId, │ + │ aspId, resId } │ + │ │ + │ Auth plugin (example): │ + │ • "skip auth" for test │ + └──────────────────────────┘ + │ + NHP-AOP │ 490 bytes (UDP) + (to AC) │ Uses RealRemoteAddr + │ for srcAddrs + ▼ + AC receives NHP-AOP + ┌──────────────────────────┐ + │ Decrypt AOP: │ + │ • Verify Server identity │ + │ • Extract srcAddrs: │ + │ 192.168.65.1 (browser) │ + │ 177.7.0.8 (gateway) │ + │ • Execute ipset rules: │ + │ add 192.168.65.1,80, │ + │ 177.7.0.10 timeout 20│ + │ add 177.7.0.8,80, │ + │ 177.7.0.10 timeout 20│ + │ │ + │ Build NHP-ART response │ + │ { errCode: "0", │ + │ opnTime: 20, │ + │ token: "..." } │ + └──────────────────────────┘ + │ + NHP-ART │ 429 bytes (UDP) + │ + ▼ + Server receives NHP-ART + ┌──────────────────────────┐ + │ Build NHP-ACK: │ + │ { errCode: "0", │ + │ resHost: { demoServer: │ + │ "localhost" }, │ + │ opnTime: 15, │ + │ agentAddr: "...", │ + │ acTokens: { ... } } │ + │ │ + │ Noise encrypt ACK │ + │ to Agent's pubkey │ + │ Output: 493 bytes │ + │ │ + │ Send via relay conn │ + │ (RemoteAddr = relay) │ + └──────────────────────────┘ + │ + UDP send │ 493 bytes + (to relay) │ + ▼ + Relay connectionRoutine + ┌──────────────────────────┐ + │ Receive ACK from Server │ + │ pkt.HeaderType == NHP_ACK│ + │ counter = pkt.Counter() │ + │ Match pendingRequests │ + │ [counter] → found! │ + │ Copy raw bytes (493B) │ + │ Forward to HTTP handler │ + │ │ + │ NOTE: Relay does NOT │ + │ decrypt the ACK — it is │ + │ opaque (encrypted to │ + │ Agent's key pair) │ + └──────────────────────────┘ + │ + HTTP 200 │ 493 bytes + (raw binary) │ + ▼ + Agent receives ACK + ┌──────────────────────────┐ + │ parseNHPPacket(ACK) │ + │ • prevChainKey = zeros │ + │ (Go server clears │ + │ chainKey after body │ + │ decryption — both │ + │ sides restart from 0) │ + │ • Verify HMAC (SM3) │ + │ • SM2_ECDH(agentPriv, │ + │ serverEphPub) │ + │ • Decrypt server pubkey │ + │ • Verify == expected │ + │ • Decrypt timestamp │ + │ • Decrypt body → JSON │ + │ • zlib decompress │ + │ │ + │ Parse ACK response: │ + │ • errCode "0" = success │ + │ • resHost, opnTime, │ + │ acTokens, agentAddr │ + └──────────────────────────┘ + │ + ▼ + KnockResult { + success: true, + resourceHosts: { demoServer: "localhost" }, + expiresAt: now + 15s, + accessToken: "...", + agentAddress: "192.168.65.1:16556" + } +``` + +### Noise Chain Key Continuity (Important Implementation Detail) + +The NHP protocol uses a Noise-IK-inspired continuous chain. However, **both the Go server and Go agent clear `chainKey` to zeros in their `encryptBody()`/`decryptBody()` defer blocks**. This means: + +- The Server encrypts the ACK response starting from an **all-zeros chain key** (not from the KNK's final ChainKey4). +- The Go agent decrypts the ACK also starting from **all-zeros chain key** (copied from the KNK's cleared MsgAssemblerData). +- The JS agent matches this behavior by passing `new Uint8Array(32)` (all-zeros) as the `prevChainKey` when parsing ACK responses. + +This is a deliberate behavior — both sides are consistent, so the protocol works correctly. The chain key is only continuous *within* a single packet's construction (across the 4 ECDH/KDF steps). Between KNK and ACK, the chain effectively restarts from zeros. + +### Noise Handshake Cryptographic Steps + +Each NHP packet (KNK, RLY, ACK, etc.) follows the same Noise-IK-inspired construction: + +```text +Initiator (sender) Responder (receiver) +───────────────── ────────────────────── + +1. ChainHash = H(InitialHashString) 1. ChainHash = H(InitialHashString) + ChainKey = KDF(H(InitialHashString), ChainKey = KDF(H(InitialHashString), + InitialChainKeyString) InitialChainKeyString) + +2. ChainHash += responderPubKey 2. ChainHash += devicePubKey (own) + ChainHash += ephemeralPubKey ChainHash += ephemeralPubKey (from header) + ChainKey = KDF(ChainKey, ephemeralPubKey) ChainKey = KDF(ChainKey, ephemeralPubKey) + +3. ESS = ECDH(ephemeralPrivKey, remotePubKey) 3. ESS = ECDH(devicePrivKey, ephemeralPubKey) + [ChainKey, AeadKey] = KDF2(ChainKey, ESS) [ChainKey, AeadKey] = KDF2(ChainKey, ESS) + +4. header.static = AEAD.Seal( 4. senderPubKey = AEAD.Open( + AeadKey, nonce, AeadKey, nonce, + senderPubKey, ← plaintext header.static, ← ciphertext + ChainHash) ← additional data ChainHash) ← additional data + ChainHash += header.static ChainHash += header.static + +5. SS = ECDH(senderPrivKey, remotePubKey) 5. SS = ECDH(devicePrivKey, senderPubKey) + [ChainKey, AeadKey] = KDF2(ChainKey, SS) [ChainKey, AeadKey] = KDF2(ChainKey, SS) + +6. header.timestamp = AEAD.Seal( 6. timestamp = AEAD.Open( + AeadKey, nonce, timestamp, ChainHash) AeadKey, nonce, header.timestamp, ChainHash) + ChainHash += header.timestamp ChainHash += header.timestamp + → Anti-replay check (counter + timestamp) + +7. [ChainKey, AeadKey] = KDF2(ChainKey, ts) 7. [ChainKey, AeadKey] = KDF2(ChainKey, ts) + body = AEAD.Seal(AeadKey, nonce, message = AEAD.Open(AeadKey, nonce, + compress(message), ChainHash) body, ChainHash) + → decompress(message) + +8. HMAC = H(InitialHashString ‖ 8. Verify: HMAC == H(InitialHashString ‖ + remotePubKey ‖ header[0:size-32]) devicePubKey ‖ header[0:size-32]) + + H = Blake2s-256 (curve25519 scheme) H = Blake2s-256 (curve25519 scheme) + H = SM3 (gmsm scheme) H = SM3 (gmsm scheme) + ECDH = X25519 (curve25519 scheme) ECDH = X25519 (curve25519 scheme) + ECDH = SM2 (gmsm scheme) ECDH = SM2 (gmsm scheme) + AEAD = AES-256-GCM (curve25519 scheme) AEAD = AES-256-GCM (curve25519 scheme) + AEAD = SM4-GCM (gmsm scheme) AEAD = SM4-GCM (gmsm scheme) + KDF = HMAC-based key derivation KDF = HMAC-based key derivation +``` + +### Key Registration Requirements + +All NHP participants must have their public keys pre-registered on the NHP Server: + +| Peer Type | Config File | Required Fields | Purpose | +| ---------------------- | ---------------- | ---------------------------- | ---------------------------------------------- | +| Agent (js-agent) | `etc/agent.toml` | `PubKeyBase64`, `ExpireTime` | Server verifies agent identity from KNK packet | +| Relay (nhp-relay) | `etc/relay.toml` | `PubKeyBase64`, `ExpireTime` | Server verifies relay identity from RLY packet | +| AC (access controller) | `etc/ac.toml` | `PubKeyBase64`, `ExpireTime` | Server communicates with AC | + +Example `agent.toml`: + +```toml +[[Agents]] +PubKeyBase64 = "ykaf9HEVQ40Ri4F8pjsJEyEitTX0d48m8gaTAIlZkAbzVYTC6q2JtnwOf/6QmFxhhbfDzdBLwWf8Gxt+SUkb4A==" +ExpireTime = 1924991999 +``` + +Example `relay.toml`: + +```toml +[[Relays]] +PubKeyBase64 = "wxTo+ybfcVsmEx5NYnSpz1ibOkWxDaYlRR4fFkQwfp/g2alyquKdffeoxBtz6LUUTX5LrTtgZk0BupQoNOGnwg==" +ExpireTime = 1924991999 +``` + +### Relay Service Configuration + +The relay service (`nhp-relay`) config file (`etc/config.toml`): + +```toml +listenIp = "0.0.0.0" +listenPort = 8080 + +enableTLS = false +tlsCertFile = "" +tlsKeyFile = "" + +# Relay's own Curve25519/SM2 private key (base64) +privateKeyBase64 = "RELAY_PRIVATE_KEY_BASE64" + +# NHP Server upstream +nhpServerHost = "127.0.0.1" +nhpServerPort = 62206 +nhpServerPublicKeyBase64 = "SERVER_PUBLIC_KEY_BASE64" + +# Timeouts (ms) +readTimeoutMs = 10000 +writeTimeoutMs = 10000 +idleTimeoutMs = 60000 +udpTimeoutMs = 5000 + +logLevel = 2 ``` +## Development + +All commands are run from the `nhp-js/` directory: + +```bash +cd nhp-js +npm install +``` + +| Command | Description | +| ------- | ----------- | +| `npm run dev` | Start Vite dev server with hot reload | +| `npm run build` | Build library to `dist/` | +| `npm run preview` | Preview the production build | +| `npm test` | Run tests in watch mode | +| `npm run test:run` | Run tests once | +| `npm run test:coverage` | Run tests with coverage report | +| `npm run lint` | Lint `src/` and `test/` | +| `npm run lint:fix` | Auto-fix lint issues | +| `npm run format` | Format code with Prettier | +| `npm run docs` | Generate TypeDoc API documentation | + +### Build Output + +The build produces: + +- `dist/index.js` — ES module +- `dist/index.cjs` — CommonJS module +- `dist/index.d.ts` — TypeScript declarations + +## Debugging + +### 1. Dev Server (Browser) + +Start the dev server and open the test harness in a browser: + +```bash +cd nhp-js +npm run dev +# Opens http://localhost:5173 +``` + +The dev server serves `nhp-js/index.html`, which loads `main.js`. Edit `main.js` to test specific scenarios — changes hot-reload automatically. + +### 2. Relay Transport Test Page + +Open `http://localhost:5173/examples/relay-test.html` for an interactive relay test page with: +- Key pair generation (saved to localStorage) +- Configurable relay URL, server public key, identity, and resource +- Real-time sequence diagram showing each step +- Detailed log output with hex dumps and timing + +### 3. Run Tests with Verbose Output + +```bash +cd nhp-js + +# Watch mode (re-runs on file change) +npm test + +# Single run with detailed output +npm run test:run -- --reporter=verbose + +# Run a specific test file +npm run test:run -- test/crypto/ecdh.test.ts + +# Run tests matching a pattern +npm run test:run -- -t "ECDH Key Exchange" +``` + +### 4. Enable SDK Debug Logging + +Set `logLevel: 'debug'` when creating the agent to see all internal operations: + +```typescript +const agent = new NHPAgent({ logLevel: 'debug' }); +``` + +Log levels: `silent` | `error` | `info` | `debug` + +### 5. Browser DevTools + +In the browser, the SDK emits events you can listen to: + +```typescript +agent.on('knock', (data) => console.log('Knock packet sent:', data)); +agent.on('ack', (data) => console.log('Server ACK received:', data)); +agent.on('error', (err) => console.error('Transport error:', err)); +``` + +### 6. Coverage Report + +```bash +cd nhp-js +npm run test:coverage +# Open coverage/index.html in a browser for the full report +``` + +### Common Issues + +**`Packet size is too small`**: The relay returned an empty or invalid response. Check relay server logs — the NHP Server may have rejected the inner packet. + +**`HMAC check failed`**: The remote public key does not match. Verify the `publicKey` in `addServer()` matches the server's actual public key. + +**`peer not found in peer pool`**: The agent's or relay's public key is not registered in the NHP Server's config. Add it to `agent.toml` or `relay.toml`. + +**`received replay packet`**: The packet counter was not monotonically increasing. This can happen after page reloads — the SDK now seeds the counter from the current timestamp to avoid this. In relay mode, this can also occur if the NHP Server reuses a stale connection — the Server's `HandleRelayForward` now uses per-client connection keys (`relay::`) to isolate anti-replay state. + +**`authentication tag mismatch` (SM4-GCM)**: AEAD decryption failed during ACK parsing. Common causes: + +- The `prevChainKey` does not match — the JS agent must use an all-zeros chain key when parsing ACK responses from Go servers (see "Noise Chain Key Continuity" above). +- The cipher scheme mismatch — ensure both sides use the same scheme (`gmsm` or `curve25519`). + +**`connection is closed, discard inbound packet`**: In relay mode, the NHP Server may hold a stale (closed) relay-forwarded connection in its connection map. This is automatically cleaned up on the next request — retry the knock. + +**`Server error: 0`**: The ACK was decrypted successfully, but `errCode: "0"` was treated as an error. In NHP, `"0"` means success. This was a JS agent bug (now fixed). + +**`Received stale packet` error**: Clocks between client and server are more than 10 minutes apart. Sync system time. + +**`Request timeout` (relay mode)**: The relay received the KNK but did not get an ACK back. Check: + +1. Relay logs — did it match the pending request counter? +2. Server logs — did the ACK get sent back to the relay's UDP address (not the real client's)? +3. The Server's `HandleRelayForward` must set `RemoteAddr = relayAddr` (not `realAddr`) so the ACK is routed back through the relay. + +**CORS errors when using relay**: The relay service includes a CORS middleware (`Access-Control-Allow-Origin: *`). If you see CORS errors, ensure the relay is running and accessible. + +## Deployment + +The `index.html` demo is deployed automatically to [js-agent.opennhp.org](https://js-agent.opennhp.org) via GitHub Pages on every push to `main`. The `CNAME` file configures the custom domain. + +CI runs on Node.js 18, 20, and 22 for every push and pull request. + ## Related Projects -- [OpenNHP](https://github.com/OpenNHP/opennhp) - Main OpenNHP repository -- [OpenNHP Documentation](https://docs.opennhp.org) - Official documentation +- [OpenNHP](https://github.com/OpenNHP/opennhp) — Main OpenNHP server and relay implementation (Go) +- [OpenNHP Documentation](https://docs.opennhp.org) — Official documentation ## License -See [LICENSE](LICENSE) file for details. +Apache 2.0 — See [LICENSE](LICENSE) for details. diff --git a/nhp-js/eslint.config.js b/nhp-js/eslint.config.js index acb5701..f84a8a7 100644 --- a/nhp-js/eslint.config.js +++ b/nhp-js/eslint.config.js @@ -7,7 +7,7 @@ export default tseslint.config( ...tseslint.configs.recommended, prettier, { - ignores: ['dist/', 'docs/', 'node_modules/', '*.config.js'], + ignores: ['dist/', 'docs/', 'node_modules/', '*.config.js', 'main.js'], }, { rules: { diff --git a/nhp-js/examples/relay-test.html b/nhp-js/examples/relay-test.html new file mode 100644 index 0000000..7926311 --- /dev/null +++ b/nhp-js/examples/relay-test.html @@ -0,0 +1,539 @@ + + + + + + NHP Relay Transport Test + + + + + + + +
+

NHP Relay Transport Test

+

Browser → HTTP Relay → NHP Server (UDP) → Relay → Browser

+ + +
+
⚙ Configuration
+
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + + + + + + + +
+ + +
+
+ Ready + + +
+ +
+ + +
+ + +
+
▶ Sequence
+
+
+
+ Initialize NHPAgent (load key pair) +
+
+
+ Set identity & add server +
+
+
+ Build KNK packet (ECDH + AEAD encrypt) +
+
+
+ POST → Relay → NHP Server (UDP) +
+
+
+ Receive & parse ACK/COK response +
+
+
+ Result +
+
+
+ + +
+
☰ Log
+
+
+
+ + + + diff --git a/nhp-js/main.js b/nhp-js/main.js index d1bc672..d1f006a 100644 --- a/nhp-js/main.js +++ b/nhp-js/main.js @@ -1,11 +1,63 @@ -import {buildNHPPacket, parseNHPPacket} from "./dist/nhp-js-lib.js" - -const privateKeyBase641 = "QL2U9Ag18Fqdc1Bpw4wx/sNJbFqq36rfhtkPm2WId1c="; -const publicKeyBase641 = "Z0QA6bPlRpq8xWsVFe8NGR43bjGHjK0mX7CT1daudAA="; -const privateKeyBase642 = "oLqkmcl6wVqUaOOrGo4xvpaUkuCThgM37DUZKGExFVE="; -const publicKeyBase642 = "E3o8gUUxI5dUAUiEOi6SvgV9bWKmjpWWQaOO6GChO2A="; -const packet = await buildNHPPacket(2, privateKeyBase641, publicKeyBase641, publicKeyBase642, "this is a test packet", true, "") -console.log("packet: ", packet) - -const obj = await parseNHPPacket(packet, privateKeyBase642, publicKeyBase642, publicKeyBase641) -console.log("obj: ", obj) +import { buildNHPPacket, parseNHPPacket, NHP_PACKET_TYPES } from "./dist/index.js" + +const agentPrivKey = "QL2U9Ag18Fqdc1Bpw4wx/sNJbFqq36rfhtkPm2WId1c="; +const agentPubKey = "Z0QA6bPlRpq8xWsVFe8NGR43bjGHjK0mX7CT1daudAA="; +const serverPrivKey = "oLqkmcl6wVqUaOOrGo4xvpaUkuCThgM37DUZKGExFVE="; +const serverPubKey = "E3o8gUUxI5dUAUiEOi6SvgV9bWKmjpWWQaOO6GChO2A="; + +console.group("=== CURVE25519 full round-trip ==="); + +// Step 1: Agent → Server (KNK) +console.group("Step 1: Agent builds KNK"); +const knockPayload = JSON.stringify({ + usrId: "user@example.com", + devId: "device-001", + aspId: "demo", + resId: "resource-1" +}); +const knkPacket = await buildNHPPacket( + NHP_PACKET_TYPES.KNK, + agentPrivKey, agentPubKey, + serverPubKey, + knockPayload, true, "curve25519" +); +console.log("KNK packet:", knkPacket.length, "bytes"); +console.groupEnd(); + +// Step 2: Server → Agent (ACK) — server replies with access grant +console.group("Step 2: Server builds ACK"); +const ackPayload = JSON.stringify({ + errCode: "", + resHost: { "demo": "192.168.1.100:8080" }, + opnTime: 300, + agentAddr: "1.2.3.4:12345" +}); +const ackPacket = await buildNHPPacket( + NHP_PACKET_TYPES.ACK, + serverPrivKey, serverPubKey, + agentPubKey, + ackPayload, true, "curve25519" +); +console.log("ACK packet:", ackPacket.length, "bytes"); +console.groupEnd(); + +// Step 3: Agent parses ACK +console.group("Step 3: Agent parses ACK"); +try { + const parsed = await parseNHPPacket( + ackPacket, + agentPrivKey, agentPubKey, + serverPubKey + ); + console.log("Packet type:", parsed.type, "(expected 2 = ACK)"); + const msg = JSON.parse(parsed.message); + console.log("Access granted!"); + console.log(" Resource host:", msg.resHost); + console.log(" Open time:", msg.opnTime, "seconds"); + console.log(" Agent address:", msg.agentAddr); +} catch (err) { + console.error("Parse failed:", err.message); +} +console.groupEnd(); + +console.groupEnd(); diff --git a/nhp-js/package-lock.json b/nhp-js/package-lock.json index 664f1e7..5c732cc 100644 --- a/nhp-js/package-lock.json +++ b/nhp-js/package-lock.json @@ -1417,7 +1417,6 @@ "integrity": "sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "json-schema-traverse": "^1.0.0", @@ -1669,7 +1668,6 @@ "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1726,7 +1724,6 @@ "integrity": "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.51.0", "@typescript-eslint/types": "8.51.0", @@ -2236,7 +2233,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2663,7 +2659,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3950,7 +3945,6 @@ "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -4472,7 +4466,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4552,7 +4545,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -4678,7 +4670,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", diff --git a/nhp-js/src/NHPAgent.ts b/nhp-js/src/NHPAgent.ts index c081f46..d8ef778 100644 --- a/nhp-js/src/NHPAgent.ts +++ b/nhp-js/src/NHPAgent.ts @@ -25,6 +25,7 @@ import { NHP_PACKET_TYPES } from './protocol/constants.js'; import { WebSocketTransport } from './transport/websocket.js'; import { UdpTransport } from './transport/udp.js'; import { WebRTCTransport } from './transport/webrtc.js'; +import { HttpRelayTransport } from './transport/relay.js'; /** Common transport interface */ interface Transport { @@ -40,7 +41,7 @@ interface Transport { const isBrowser = typeof window !== 'undefined' && typeof window.document !== 'undefined'; /** Default agent configuration */ -const DEFAULT_CONFIG: Required> = { +const DEFAULT_CONFIG: Required> = { cipherScheme: 'curve25519', logLevel: 'error', transport: isBrowser ? 'webrtc' : 'udp', @@ -84,7 +85,7 @@ const DEFAULT_CONFIG: Required> = { * ``` */ export class NHPAgent { - private config: Required> & { privateKey?: string }; + private config: Required> & { privateKey?: string; relayUrl?: string }; private keyPair: KeyPairBase64 | null = null; private identity: AgentIdentity | null = null; private servers: Map = new Map(); @@ -178,7 +179,7 @@ export class NHPAgent { * Add a server configuration */ addServer(config: ServerConfig): void { - const serverId = config.id ?? `${config.host}:${config.port}`; + const serverId = config.id ?? (config.host ? `${config.host}:${config.port}` : config.publicKey.substring(0, 16)); this.servers.set(serverId, { ...config, id: serverId }); this.log('debug', `Server added: ${serverId}`); } @@ -232,13 +233,27 @@ export class NHPAgent { }; } - const serverId = `${resource.serverHost}:${resource.serverPort}`; - const server = this.servers.get(serverId); + let server: ServerConfig | undefined; + let serverId: string; + + if (resource.serverHost && resource.serverPort) { + // Direct mode: lookup by host:port + serverId = `${resource.serverHost}:${resource.serverPort}`; + server = this.servers.get(serverId); + } else { + // Relay mode: use the first (and typically only) registered server + const first = this.servers.entries().next(); + if (!first.done) { + [serverId, server] = first.value; + } else { + serverId = ''; + } + } if (!server) { return { success: false, - error: `Server not configured: ${serverId}`, + error: `Server not configured${serverId ? ': ' + serverId : ''}. Call addServer() first.`, errorCode: 3, }; } @@ -352,23 +367,28 @@ export class NHPAgent { this.emit('knock', { resource, packetType, packet }); // Get or create transport - const serverId = `${resource.serverHost}:${resource.serverPort}`; + const serverId = server.id!; let transport = this.transports.get(serverId); if (!transport) { - transport = this.createTransport(resource.serverHost, resource.serverPort); + transport = this.createTransport(server.host ?? '', server.port ?? 0); this.transports.set(serverId, transport); } + // The Go server's encryptBody/decryptBody both clear chainKey via defer, + // so both sides of the Noise chain effectively restart from all-zeros. + // Pass an all-zeros prevChainKey to match this behavior. + const zeroChainKey = new Uint8Array(32); + // Send packet and wait for response - return this.sendAndWaitForResponse(transport, packet, server.publicKey); + return this.sendAndWaitForResponse(transport, packet, server.publicKey, zeroChainKey); } private parseAckResponse(message: string): KnockResult { try { const ackMsg: ServerKnockAckMsg = JSON.parse(message); - // Check for error - if (ackMsg.errCode && ackMsg.errCode !== '') { + // Check for error — errCode "0" or "" means success + if (ackMsg.errCode && ackMsg.errCode !== '' && ackMsg.errCode !== '0') { return { success: false, error: ackMsg.errMsg || `Server error: ${ackMsg.errCode}`, @@ -427,6 +447,17 @@ export class NHPAgent { autoReconnect: false, }) as Transport; + case 'relay': { + const relayUrl = this.config.relayUrl; + if (!relayUrl) { + throw new Error( + '[NHPAgent] transport="relay" requires relayUrl to be set in NHPAgentConfig' + ); + } + this.log('debug', `Creating HTTP relay transport via ${relayUrl}`); + return new HttpRelayTransport({ relayUrl }) as unknown as Transport; + } + default: throw new Error(`Unsupported transport type: ${transportType}`); } @@ -435,7 +466,8 @@ export class NHPAgent { private async sendAndWaitForResponse( transport: Transport, packet: Uint8Array, - serverPublicKey: string + serverPublicKey: string, + prevChainKey?: Uint8Array ): Promise { return new Promise((resolve, reject) => { const timeout = setTimeout(() => { @@ -453,12 +485,36 @@ export class NHPAgent { } const message = msg as { data: Uint8Array }; - const parsed = await parseNHPPacket( - message.data, - this.keyPair.privateKey, - this.keyPair.publicKey, - serverPublicKey - ); + + // Try parsing with the previous chain key first (real server + // continues the Noise chain from the KNK). If that fails, fall + // back to a fresh chain key (self-test / standalone ACK). + let parsed: ParsedPacket; + if (prevChainKey) { + try { + parsed = await parseNHPPacket( + message.data, + this.keyPair.privateKey, + this.keyPair.publicKey, + serverPublicKey, + prevChainKey + ); + } catch { + parsed = await parseNHPPacket( + message.data, + this.keyPair.privateKey, + this.keyPair.publicKey, + serverPublicKey + ); + } + } else { + parsed = await parseNHPPacket( + message.data, + this.keyPair.privateKey, + this.keyPair.publicKey, + serverPublicKey + ); + } resolve(parsed); } catch (err) { diff --git a/nhp-js/src/crypto/sm2.ts b/nhp-js/src/crypto/sm2.ts index 728614e..495103b 100644 --- a/nhp-js/src/crypto/sm2.ts +++ b/nhp-js/src/crypto/sm2.ts @@ -107,12 +107,12 @@ export function sm2ECDH(privateKey: Uint8Array, publicKey: Uint8Array): Uint8Arr // Add 04 prefix for uncompressed point format const publicKeyHex = '04' + bytesToHex(publicKey); - // sm2.ecdh returns the shared secret as Uint8Array + // sm2.ecdh returns a compressed EC point (33 bytes: 02/03 prefix + 32-byte X coordinate). + // The shared secret is the X coordinate only (matching Go's crypto/ecdh convention). const sharedSecret = sm2.ecdh(privateKeyHex, publicKeyHex); - // Return only the X coordinate (first 32 bytes) as the shared secret - // This matches the Go implementation - return sharedSecret.slice(0, 32); + // Skip the 1-byte compression prefix, return the 32-byte X coordinate + return sharedSecret.slice(1, 33); } /** diff --git a/nhp-js/src/index.ts b/nhp-js/src/index.ts index e847e85..738602e 100644 --- a/nhp-js/src/index.ts +++ b/nhp-js/src/index.ts @@ -87,5 +87,5 @@ export { export type { INHPHeader, TypeAndPayloadSize } from './protocol/index.js'; // Transport (for advanced usage) -export { WebSocketTransport, UdpTransport, WebRTCTransport } from './transport/index.js'; -export type { UdpTransportConfig, WebRTCTransportConfig, WebRTCTransportEvent } from './transport/index.js'; +export { WebSocketTransport, UdpTransport, WebRTCTransport, HttpRelayTransport } from './transport/index.js'; +export type { UdpTransportConfig, WebRTCTransportConfig, WebRTCTransportEvent, HttpRelayTransportConfig } from './transport/index.js'; diff --git a/nhp-js/src/protocol/packet.ts b/nhp-js/src/protocol/packet.ts index ef45f20..6221ddc 100644 --- a/nhp-js/src/protocol/packet.ts +++ b/nhp-js/src/protocol/packet.ts @@ -37,6 +37,7 @@ import { import { sm3, hmacSM3, + newSM3Hash, keyGenSM3_2, mixKeySM3, mixHashSM3, @@ -44,6 +45,7 @@ import { import { sm4GcmSeal, sm4GcmOpen } from '../crypto/sm4.js'; import { base64ToBytes, + bytesToBase64, stringToBytes, bytesToString, equalBytes, @@ -54,11 +56,33 @@ import { } from '../crypto/utils.js'; // Global state for packet management -let globalCounter = 0n; +// Seed counter from current time so page reloads don't trigger anti-replay on server +let globalCounter = BigInt(Date.now()) * 1000000n; // milliseconds → nanoseconds const serverCookieMap = new Map(); const lastSendTimeMap = new Map(); const lastRemoteSendTimeMap = new Map(); +// Stores the chain key produced by buildNHPPacket, keyed by remotePublicKey. +// The NHP Noise protocol is a continuous chain: the server's ACK is encrypted +// starting from the chain key left over after decrypting the agent's KNK. +// parseNHPPacket uses this saved key as the starting point instead of +// re-deriving from scratch. Only populated when callers pass +// prevChainKey to parseNHPPacket (see NHPAgent). +const lastBuildChainKeyMap = new Map(); + +/** + * Retrieve and consume the chain key saved by the most recent buildNHPPacket + * call for the given remotePublicKey. Returns undefined if no key was saved. + * The entry is deleted after retrieval so it cannot be re-used accidentally. + */ +export function consumeLastBuildChainKey(remotePublicKey: string): Uint8Array | undefined { + const ck = lastBuildChainKeyMap.get(remotePublicKey); + if (ck) { + lastBuildChainKeyMap.delete(remotePublicKey); + } + return ck; +} + /** * Build an NHP packet for transmission * @param type - Packet type (KNK, ACK, etc.) @@ -185,6 +209,10 @@ export async function buildNHPPacket( updateBlake2s(hmacHasher, packet.subarray(0, header.size - FIELD_SIZES.HMAC)); header.hmac = sumBlake2s(hmacHasher); + // Save the final chain key so parseNHPPacket can continue the Noise chain + // when decrypting the server's ACK response. + lastBuildChainKeyMap.set(remotePublicKey, new Uint8Array(chainKey)); + return packet.subarray(0, header.size + payloadSize); } @@ -200,20 +228,23 @@ export async function parseNHPPacket( packet: Uint8Array, privateKey: string, publicKey: string, - remotePublicKey: string + remotePublicKey: string, + prevChainKey?: Uint8Array ): Promise { if (packet.length < HEADER_SIZE) { throw new Error('Packet size is too small'); } - // Check if this is an extended (GMSM) packet by checking size or flags - // Extended packets are at least 304 bytes - if (packet.length >= HEADER_EX_SIZE) { - // Peek at flags to check if extended - const flagByte = packet[10] | (packet[11] << 8); - if (flagByte & 0x1) { - return parseNHPPacketGMSM(packet, privateKey, publicKey, remotePublicKey); - } + // Check if this is an extended (GMSM) packet by reading the flags field directly. + // The flags field is at a fixed offset (10-11) in both standard and extended headers, + // so we can safely peek at it before deciding which parser to use. + // Do NOT rely on packet.length >= HEADER_EX_SIZE: a GMSM packet with a small payload + // can be shorter than 304 bytes, causing misrouting. + // Read flags as big-endian uint16 to match DataView.getUint16 used in NHPHeader/NHPHeaderEx. + // FLAGS field is at offset 10-11. Big-endian: high byte at [10], low byte at [11]. + const flagByte = (packet[10] << 8) | packet[11]; + if (flagByte & 0x1) { + return parseNHPPacketGMSM(packet, privateKey, publicKey, remotePublicKey, prevChainKey); } // Create a clean ArrayBuffer copy to avoid SharedArrayBuffer issues @@ -256,14 +287,22 @@ export async function parseNHPPacket( const tsStatic = header.timestamp; const msgStatic = packet.subarray(header.size); - // Initialize chain key and hash + // Initialize chain key and hash. + // The NHP Noise protocol is a continuous chain: the server encrypts its ACK + // starting from the chain key left over after decrypting the agent's KNK. + // When prevChainKey is provided, resume from it; otherwise start fresh. const chainKey = new Uint8Array(32); const chainHash = new Uint8Array(32); const chainHasher = newBlake2sHash(); updateBlake2s(chainHasher, stringToBytes(INITIAL_HASH_STRING)); chainHash.set(sumBlake2s(chainHasher)); - chainKey.set(mixKey(chainHash, stringToBytes(INITIAL_CHAIN_KEY_STRING))); + + if (prevChainKey) { + chainKey.set(prevChainKey); + } else { + chainKey.set(mixKey(chainHash, stringToBytes(INITIAL_CHAIN_KEY_STRING))); + } updateBlake2s(chainHasher, localPubKeyBytes); updateBlake2s(chainHasher, ephemeralPublicKeyBytes); @@ -352,6 +391,7 @@ export function clearServerCookie(remotePublicKey: string): void { */ export function resetGlobalCounter(): void { globalCounter = 0n; + lastBuildChainKeyMap.clear(); } /** @@ -381,36 +421,48 @@ async function buildNHPPacketGMSM( header.counter = globalCounter; const nonce = header.nonce; - // Initialize chain key and hash using SM3 - let chainKey = sm3(stringToBytes(INITIAL_HASH_STRING)); - let chainHash = sm3(stringToBytes(INITIAL_HASH_STRING)); - chainKey = mixKeySM3(chainHash, stringToBytes(INITIAL_CHAIN_KEY_STRING))[0]; + // Initialize chain key and chain hash using SM3 (streaming, matching Go's hash.Hash pattern) + const chainHasher = newSM3Hash(); + chainHasher.update(stringToBytes(INITIAL_HASH_STRING)); // ChainHash0 state + const chainHash0 = chainHasher.sum(); + let chainKey = mixKeySM3(chainHash0, stringToBytes(INITIAL_CHAIN_KEY_STRING))[0]; - // HMAC data accumulator + // HMAC data accumulator (plain SM3, not HMAC-SM3) let hmacData = concatBytes(stringToBytes(INITIAL_HASH_STRING), remotePubKeyBytes); - // Mix in remote public key - chainHash = mixHashSM3(chainHash, remotePubKeyBytes); + // Mix in remote public key → ChainHash0 state + remotePubKey + chainHasher.update(remotePubKeyBytes); // Generate ephemeral SM2 key pair and perform ECDH const ephemeralKeys = generateSM2KeyPair(); const ephemeralPublicKeyBytes = ephemeralKeys.publicKey; header.ephemeral = ephemeralPublicKeyBytes; - chainHash = mixHashSM3(chainHash, ephemeralPublicKeyBytes); + // ChainHash state += ephemeralPubKey + chainHasher.update(ephemeralPublicKeyBytes); chainKey = mixKeySM3(chainKey, ephemeralPublicKeyBytes)[0]; // SM2 ECDH: ephemeral private * remote public const ess = sm2ECDH(ephemeralKeys.privateKey, remotePubKeyBytes); - // Encrypt local public key using SM4-GCM + // Encrypt local public key using SM4-GCM (AD = current chainHash snapshot) const derivedKeys0 = keyGenSM3_2(chainKey, ess); chainKey = derivedKeys0[0]; - const keyStatic = sm4GcmSeal(derivedKeys0[1].slice(0, 16), nonce, localPubKeyBytes, chainHash); + const chainHashSnap = chainHasher.sum(); + // Debug: print intermediate values for cross-implementation comparison + console.debug('[GMSM-BUILD] chainHash(after pubkey+ephemeral):', bytesToBase64(chainHashSnap)); + console.debug('[GMSM-BUILD] chainKey(after MixKey):', bytesToBase64(chainKey)); + console.debug('[GMSM-BUILD] ess:', bytesToBase64(ess)); + console.debug('[GMSM-BUILD] aead key:', bytesToBase64(derivedKeys0[1].slice(0, 16))); + console.debug('[GMSM-BUILD] nonce:', bytesToBase64(nonce)); + console.debug('[GMSM-BUILD] localPubKey len:', localPubKeyBytes.length, 'remotePubKey len:', remotePubKeyBytes.length); + + const keyStatic = sm4GcmSeal(derivedKeys0[1].slice(0, 16), nonce, localPubKeyBytes, chainHashSnap); header.static = keyStatic; - chainHash = mixHashSM3(chainHash, keyStatic); + // Evolve chainHash with static ciphertext + chainHasher.update(keyStatic); // SM2 ECDH: local private * remote public const ss = sm2ECDH(localPrivKeyBytes, remotePubKeyBytes); @@ -426,26 +478,27 @@ async function buildNHPPacketGMSM( const ts = new Uint8Array(tsBuf); lastSendTimeMap.set(remotePublicKey, timestamp); - const tsStatic = sm4GcmSeal(derivedKeys1[1].slice(0, 16), nonce, ts, chainHash); + const tsStatic = sm4GcmSeal(derivedKeys1[1].slice(0, 16), nonce, ts, chainHasher.sum()); header.timestamp = tsStatic; // Encrypt message payload const derivedKeys2 = keyGenSM3_2(chainKey, tsStatic); chainKey = derivedKeys2[0]; - chainHash = mixHashSM3(chainHash, tsStatic); + chainHasher.update(tsStatic); let payload = msgBytes; if (compress) { payload = await zlibCompress(msgBytes); } - const msgStatic = sm4GcmSeal(derivedKeys2[1].slice(0, 16), nonce, payload, chainHash); + const msgStatic = sm4GcmSeal(derivedKeys2[1].slice(0, 16), nonce, payload, chainHasher.sum()); packet.set(msgStatic, header.size); const payloadSize = payload.byteLength + FIELD_SIZES.AEAD_TAG; header.typeAndPayloadSize = { type, size: payloadSize }; - // Compute HMAC using SM3 + // Compute HMAC using plain SM3 hash (matching Go server's hmacHash pattern): + // SM3(InitialHashString || serverPubKey || [cookie] || header[0:size-32]) if (type === NHP_PACKET_TYPES.RNK) { const cookie = serverCookieMap.get(remotePublicKey); if (cookie) { @@ -453,7 +506,11 @@ async function buildNHPPacketGMSM( } } hmacData = concatBytes(hmacData, packet.subarray(0, header.size - FIELD_SIZES.HMAC)); - header.hmac = hmacSM3(chainHash, hmacData); + header.hmac = sm3(hmacData); + + // Save the final chain key so parseNHPPacketGMSM can continue the Noise chain + // when decrypting the server's ACK response. + lastBuildChainKeyMap.set(remotePublicKey, new Uint8Array(chainKey)); return packet.subarray(0, header.size + payloadSize); } @@ -465,7 +522,8 @@ async function parseNHPPacketGMSM( packet: Uint8Array, privateKey: string, publicKey: string, - remotePublicKey: string + remotePublicKey: string, + prevChainKey?: Uint8Array ): Promise { // Create a clean ArrayBuffer copy const packetBuffer = new ArrayBuffer(packet.length); @@ -490,14 +548,14 @@ async function parseNHPPacketGMSM( const localPubKeyBytes = base64ToBytes(publicKey); const remotePubKeyBytes = base64ToBytes(remotePublicKey); - // Verify HMAC using SM3 + // Verify HMAC using plain SM3 hash (matching Go server's hmacHash pattern): + // SM3(InitialHashString || localPubKey || header[0:size-32]) const hmacData = concatBytes( stringToBytes(INITIAL_HASH_STRING), localPubKeyBytes, packet.subarray(0, header.size - FIELD_SIZES.HMAC) ); - const chainHashInit = sm3(stringToBytes(INITIAL_HASH_STRING)); - const checkSum = hmacSM3(chainHashInit, hmacData); + const checkSum = sm3(hmacData); if (!equalBytes(checkSum, header.hmac)) { throw new Error('HMAC check failed'); @@ -509,28 +567,36 @@ async function parseNHPPacketGMSM( const tsStatic = header.timestamp; const msgStatic = packet.subarray(header.size); - // Initialize chain key and hash using SM3 - let chainKey = sm3(stringToBytes(INITIAL_HASH_STRING)); - let chainHash = sm3(stringToBytes(INITIAL_HASH_STRING)); - chainKey = mixKeySM3(chainHash, stringToBytes(INITIAL_CHAIN_KEY_STRING))[0]; - - chainHash = mixHashSM3(chainHash, localPubKeyBytes); - chainHash = mixHashSM3(chainHash, ephemeralPublicKeyBytes); + // Initialize chain key and chain hash using SM3 (streaming, matching Go's hash.Hash pattern). + // The NHP Noise protocol is a continuous chain: the server encrypts its ACK + // starting from the chain key left over after decrypting the agent's KNK. + // When prevChainKey is provided, resume from it; otherwise start fresh. + const chainHasher = newSM3Hash(); + chainHasher.update(stringToBytes(INITIAL_HASH_STRING)); // ChainHash0 state + const chainHash0 = chainHasher.sum(); + + let chainKey = prevChainKey + ? new Uint8Array(prevChainKey) + : mixKeySM3(chainHash0, stringToBytes(INITIAL_CHAIN_KEY_STRING))[0]; + + // Note: for parse (responder), we use localPubKey (= device public key on responder side) + chainHasher.update(localPubKeyBytes); + chainHasher.update(ephemeralPublicKeyBytes); chainKey = mixKeySM3(chainKey, ephemeralPublicKeyBytes)[0]; // SM2 ECDH: local private * ephemeral public const ess = sm2ECDH(localPrivKeyBytes, ephemeralPublicKeyBytes); - // Decrypt remote public key + // Decrypt remote public key (AD = current chainHash snapshot) const derivedKeys0 = keyGenSM3_2(chainKey, ess); chainKey = derivedKeys0[0]; - const decryptedPubKeyBytes = sm4GcmOpen(derivedKeys0[1].slice(0, 16), nonce, keyStatic, chainHash); + const decryptedPubKeyBytes = sm4GcmOpen(derivedKeys0[1].slice(0, 16), nonce, keyStatic, chainHasher.sum()); if (!equalBytes(remotePubKeyBytes, decryptedPubKeyBytes)) { throw new Error('Remote public key check failed'); } - chainHash = mixHashSM3(chainHash, keyStatic); + chainHasher.update(keyStatic); // SM2 ECDH: local private * remote public const ss = sm2ECDH(localPrivKeyBytes, remotePubKeyBytes); @@ -539,7 +605,7 @@ async function parseNHPPacketGMSM( const derivedKeys1 = keyGenSM3_2(chainKey, ss); chainKey = derivedKeys1[0]; - const decryptedTs = sm4GcmOpen(derivedKeys1[1].slice(0, 16), nonce, tsStatic, chainHash); + const decryptedTs = sm4GcmOpen(derivedKeys1[1].slice(0, 16), nonce, tsStatic, chainHasher.sum()); const tsBuf = new ArrayBuffer(decryptedTs.length); new Uint8Array(tsBuf).set(decryptedTs); const tsView = new DataView(tsBuf); @@ -565,9 +631,9 @@ async function parseNHPPacketGMSM( // Decrypt message const derivedKeys2 = keyGenSM3_2(chainKey, header.timestamp); chainKey = derivedKeys2[0]; - chainHash = mixHashSM3(chainHash, tsStatic); + chainHasher.update(tsStatic); - let msg = sm4GcmOpen(derivedKeys2[1].slice(0, 16), nonce, msgStatic, chainHash); + let msg = sm4GcmOpen(derivedKeys2[1].slice(0, 16), nonce, msgStatic, chainHasher.sum()); if (compressed) { msg = await zlibDecompress(msg); diff --git a/nhp-js/src/transport/index.ts b/nhp-js/src/transport/index.ts index 6bfc35e..085d408 100644 --- a/nhp-js/src/transport/index.ts +++ b/nhp-js/src/transport/index.ts @@ -7,3 +7,5 @@ export { UdpTransport } from './udp.js'; export type { UdpTransportConfig } from './udp.js'; export { WebRTCTransport } from './webrtc.js'; export type { WebRTCTransportConfig, WebRTCTransportEvent } from './webrtc.js'; +export { HttpRelayTransport } from './relay.js'; +export type { HttpRelayTransportConfig } from './relay.js'; diff --git a/nhp-js/src/transport/relay.ts b/nhp-js/src/transport/relay.ts new file mode 100644 index 0000000..cbfa395 --- /dev/null +++ b/nhp-js/src/transport/relay.ts @@ -0,0 +1,160 @@ +/** + * HTTP Relay Transport for Browser Environments + * + * Forwards NHP packets to the NHP Server via an intermediary HTTP relay + * service instead of a direct WebRTC / WebSocket connection. + * + * Flow: + * NHPAgent ──send(KNK)──▶ HttpRelayTransport + * │ POST /relay (binary body = raw KNK packet) + * ▼ + * nhp-relay service + * │ UDP NHLY frame (realClientIP + KNK) + * ▼ + * NHP Server + * │ UDP ACK/COK (encrypted to original agent) + * ▼ + * nhp-relay service + * │ HTTP 200 (binary body = raw ACK/COK bytes) + * ▼ + * NHPAgent ◀──message──── HttpRelayTransport + * + * The transport is "virtually connected" as soon as it's created — there is no + * persistent connection to maintain (each send() call opens and closes one + * HTTP request). The connect/disconnect methods are no-ops provided for + * interface compatibility. + */ + +import type { TransportEvent, EventHandler } from '../types.js'; + +/** Configuration for the HTTP relay transport */ +export interface HttpRelayTransportConfig { + /** + * Full URL of the relay endpoint, e.g. "https://relay.example.com/relay" + * or "http://localhost:8080/relay". + */ + relayUrl: string; + + /** + * Request timeout in milliseconds (default: 10000). + * Should be ≥ the relay's udpTimeoutMs setting. + */ + timeoutMs?: number; +} + +/** + * HTTP Relay transport — sends NHP packets over HTTPS and receives + * ACK/COK responses in the HTTP response body. + */ +export class HttpRelayTransport { + private readonly config: Required; + private readonly eventHandlers: Map> = new Map(); + private connected = false; + + constructor(config: HttpRelayTransportConfig) { + this.config = { + timeoutMs: 10_000, + ...config, + }; + } + + // ─── Transport interface ────────────────────────────────────────────────── + + /** Always resolves immediately — no persistent connection needed. */ + async connect(): Promise { + this.connected = true; + // Emit 'open' asynchronously so callers can attach handlers after connect(). + setTimeout(() => this.emit('open', undefined), 0); + } + + /** Marks the transport as disconnected. */ + disconnect(): void { + this.connected = false; + this.emit('close', undefined); + } + + /** Returns true if connect() has been called and disconnect() has not. */ + isConnected(): boolean { + return this.connected; + } + + /** + * Sends an NHP packet to the relay and delivers the response via the + * 'message' event. + * + * @throws Error if the relay returns a non-200 status or the request times + * out. + */ + async send(data: Uint8Array): Promise { + if (!this.connected) { + throw new Error('[HttpRelayTransport] not connected'); + } + + let respBytes: Uint8Array; + try { + respBytes = await this.postToRelay(data); + } catch (err) { + this.emit('error', err); + throw err; + } + + this.emit('message', { data: respBytes }); + } + + on(event: TransportEvent, handler: EventHandler): void { + if (!this.eventHandlers.has(event)) { + this.eventHandlers.set(event, new Set()); + } + this.eventHandlers.get(event)!.add(handler); + } + + off(event: TransportEvent, handler: EventHandler): void { + this.eventHandlers.get(event)?.delete(handler); + } + + // ─── Internal ───────────────────────────────────────────────────────────── + + private async postToRelay(packet: Uint8Array): Promise { + const { relayUrl, timeoutMs } = this.config; + + // Always copy to a clean ArrayBuffer — packet may be a view into a larger buffer + const body = new Uint8Array(packet).buffer; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + let response: Response; + try { + response = await fetch(relayUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/octet-stream' }, + body: body as ArrayBuffer, + signal: controller.signal, + }); + } catch (err) { + clearTimeout(timer); + if ((err as Error).name === 'AbortError') { + throw new Error(`[HttpRelayTransport] request to ${relayUrl} timed out after ${timeoutMs}ms`); + } + throw new Error(`[HttpRelayTransport] fetch failed: ${(err as Error).message}`); + } finally { + clearTimeout(timer); + } + + if (!response.ok) { + const errBody = await response.text().catch(() => ''); + throw new Error( + `[HttpRelayTransport] relay returned HTTP ${response.status}: ${errBody.trim() || response.statusText}` + ); + } + + const buf = await response.arrayBuffer(); + const respBytes = new Uint8Array(buf); + console.debug(`[HttpRelayTransport] sent ${packet.byteLength}B, received ${respBytes.byteLength}B (HTTP ${response.status})`); + return respBytes; + } + + private emit(event: TransportEvent, data: unknown): void { + this.eventHandlers.get(event)?.forEach((h) => h(data)); + } +} diff --git a/nhp-js/src/types.ts b/nhp-js/src/types.ts index 3b33b9a..9929a3f 100644 --- a/nhp-js/src/types.ts +++ b/nhp-js/src/types.ts @@ -9,7 +9,7 @@ export type CipherScheme = 'curve25519' | 'gmsm'; export type LogLevel = 'silent' | 'error' | 'info' | 'debug'; /** Transport type for NHP communication */ -export type TransportType = 'udp' | 'webrtc' | 'websocket'; +export type TransportType = 'udp' | 'webrtc' | 'websocket' | 'relay'; /** Configuration for initializing the NHP Agent */ export interface NHPAgentConfig { @@ -21,18 +21,23 @@ export interface NHPAgentConfig { logLevel?: LogLevel; /** Transport type to use (default: 'udp' for Node.js, 'webrtc' for browser) */ transport?: TransportType; + /** + * Full URL of the HTTP relay endpoint (required when transport='relay'). + * Example: "https://relay.example.com/relay" + */ + relayUrl?: string; } /** Configuration for an NHP server */ export interface ServerConfig { - /** Unique identifier for the server */ + /** Unique identifier for the server (auto-generated from host:port if omitted) */ id?: string; /** Base64-encoded public key of the server */ publicKey: string; - /** Server hostname or IP address */ - host: string; - /** Server port number */ - port: number; + /** Server hostname or IP address (required for udp/webrtc/websocket, optional for relay) */ + host?: string; + /** Server port number (required for udp/webrtc/websocket, optional for relay) */ + port?: number; /** Optional expiration timestamp (Unix milliseconds) */ expiresAt?: number; } @@ -43,10 +48,10 @@ export interface ResourceConfig { resourceId: string; /** Service identifier */ serviceId: string; - /** Server hostname for the knock */ - serverHost: string; - /** Server port for the knock */ - serverPort: number; + /** Server hostname for the knock (optional for relay transport) */ + serverHost?: string; + /** Server port for the knock (optional for relay transport) */ + serverPort?: number; } /** Result of a knock operation */ diff --git a/nhp-js/test/NHPAgent.test.ts b/nhp-js/test/NHPAgent.test.ts new file mode 100644 index 0000000..93dd877 --- /dev/null +++ b/nhp-js/test/NHPAgent.test.ts @@ -0,0 +1,530 @@ +/** + * Tests for NHPAgent high-level SDK class + * + * Transport is mocked via vi.spyOn on the private createTransport method + * so tests run without any network dependency. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { NHPAgent } from '../src/NHPAgent.js'; +import { buildNHPPacket, resetGlobalCounter } from '../src/protocol/packet.js'; +import { NHP_PACKET_TYPES } from '../src/protocol/constants.js'; +import { generateX25519KeyPairBase64 } from '../src/crypto/ecdh.js'; +import { generateSM2KeyPairBase64 } from '../src/crypto/sm2.js'; +import type { EventHandler } from '../src/types.js'; + +// ─── Mock transport factory ─────────────────────────────────────────────────── + +/** + * Creates a mock transport that immediately delivers a pre-built response packet + * when send() is called. + */ +function makeMockTransport(responsePacket: Uint8Array) { + const handlers = new Map>(); + + return { + connect: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn(), + isConnected: vi.fn().mockReturnValue(false), + send: vi.fn().mockImplementation(() => { + // Deliver the response asynchronously, simulating network round-trip + const messageHandlers = handlers.get('message'); + if (messageHandlers) { + setTimeout(() => { + messageHandlers.forEach(h => h({ data: responsePacket })); + }, 0); + } + }), + on: vi.fn().mockImplementation((event: string, handler: EventHandler) => { + if (!handlers.has(event)) handlers.set(event, new Set()); + handlers.get(event)!.add(handler); + }), + off: vi.fn().mockImplementation((event: string, handler: EventHandler) => { + handlers.get(event)?.delete(handler); + }), + }; +} + +/** + * Injects a mock transport into a NHPAgent instance by spying on createTransport. + */ +function injectMockTransport(agent: NHPAgent, responsePacket: Uint8Array) { + const mock = makeMockTransport(responsePacket); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.spyOn(agent as any, 'createTransport').mockReturnValue(mock); + return mock; +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +async function makeAckPacket( + serverPrivKey: string, + serverPubKey: string, + agentPubKey: string, + payload: object +) { + return buildNHPPacket( + NHP_PACKET_TYPES.ACK, + serverPrivKey, serverPubKey, + agentPubKey, + JSON.stringify(payload), + true, + 'curve25519' + ); +} + +const SUCCESS_ACK = { + errCode: '', + resHost: { 'my-service': '10.0.0.1:8080' }, + opnTime: 300, + agentAddr: '1.2.3.4:5000', +}; + +beforeEach(() => { + resetGlobalCounter(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// ─── Initialization ─────────────────────────────────────────────────────────── + +describe('NHPAgent initialization', () => { + it('initializes with default config', async () => { + const agent = new NHPAgent(); + await agent.init(); + expect(agent.getPublicKey()).toBeTruthy(); + expect(agent.getPublicKey().length).toBe(44); // base64 of 32 bytes + await agent.close(); + }); + + it('init is idempotent — calling twice does not throw', async () => { + const agent = new NHPAgent(); + await agent.init(); + await expect(agent.init()).resolves.toBeUndefined(); + await agent.close(); + }); + + it('generates a different key pair each time', async () => { + const a = new NHPAgent(); + const b = new NHPAgent(); + await a.init(); + await b.init(); + expect(a.getPublicKey()).not.toBe(b.getPublicKey()); + await a.close(); + await b.close(); + }); + + it('uses provided private key and derives correct public key', async () => { + const { privateKey, publicKey } = generateX25519KeyPairBase64(); + const agent = new NHPAgent({ privateKey }); + await agent.init(); + expect(agent.getPublicKey()).toBe(publicKey); + await agent.close(); + }); + + it('getPublicKey throws before init', () => { + const agent = new NHPAgent(); + expect(() => agent.getPublicKey()).toThrow('Agent not initialized'); + }); + + it('initializes with gmsm cipher scheme', async () => { + const agent = new NHPAgent({ cipherScheme: 'gmsm' }); + await agent.init(); + // SM2 public key is 64 bytes → base64 = 88 chars + expect(agent.getPublicKey().length).toBe(88); + await agent.close(); + }); + + it('uses provided SM2 private key', async () => { + const { privateKey, publicKey } = generateSM2KeyPairBase64(); + const agent = new NHPAgent({ cipherScheme: 'gmsm', privateKey }); + await agent.init(); + expect(agent.getPublicKey()).toBe(publicKey); + await agent.close(); + }); +}); + +// ─── Server management ──────────────────────────────────────────────────────── + +describe('Server management', () => { + it('addServer and removeServer work without throwing', async () => { + const agent = new NHPAgent(); + await agent.init(); + + agent.addServer({ publicKey: 'fakepubkey', host: 'example.com', port: 62206 }); + agent.removeServer('example.com:62206'); + + await agent.close(); + }); + + it('removeServer with unknown id does not throw', async () => { + const agent = new NHPAgent(); + await agent.init(); + expect(() => agent.removeServer('nonexistent')).not.toThrow(); + await agent.close(); + }); + + it('addServer uses explicit id when provided', async () => { + const agent = new NHPAgent(); + await agent.init(); + + agent.addServer({ id: 'primary', publicKey: 'key', host: 'nhp.example.com', port: 62206 }); + // Should be removable by the explicit id + expect(() => agent.removeServer('primary')).not.toThrow(); + + await agent.close(); + }); +}); + +// ─── Identity ───────────────────────────────────────────────────────────────── + +describe('Identity', () => { + it('setIdentity does not throw', async () => { + const agent = new NHPAgent(); + await agent.init(); + expect(() => agent.setIdentity({ + userId: 'user@example.com', + deviceId: 'device-001', + organizationId: 'example.org', + })).not.toThrow(); + await agent.close(); + }); + + it('setUser (legacy) does not throw', async () => { + const agent = new NHPAgent(); + await agent.init(); + expect(() => agent.setUser('user@example.com', 'example.org')).not.toThrow(); + await agent.close(); + }); +}); + +// ─── knockResource error cases (no transport needed) ───────────────────────── + +describe('knockResource — precondition errors', () => { + it('returns errorCode 1 when agent not initialized', async () => { + const agent = new NHPAgent(); + const result = await agent.knockResource({ + resourceId: 'res', serviceId: 'svc', + serverHost: 'example.com', serverPort: 62206, + }); + expect(result.success).toBe(false); + expect(result.errorCode).toBe(1); + }); + + it('returns errorCode 2 when identity not set', async () => { + const agent = new NHPAgent(); + await agent.init(); + + const result = await agent.knockResource({ + resourceId: 'res', serviceId: 'svc', + serverHost: 'example.com', serverPort: 62206, + }); + expect(result.success).toBe(false); + expect(result.errorCode).toBe(2); + await agent.close(); + }); + + it('returns errorCode 3 when server not configured', async () => { + const agent = new NHPAgent(); + await agent.init(); + agent.setIdentity({ userId: 'u', deviceId: 'd' }); + + const result = await agent.knockResource({ + resourceId: 'res', serviceId: 'svc', + serverHost: 'example.com', serverPort: 62206, + }); + expect(result.success).toBe(false); + expect(result.errorCode).toBe(3); + await agent.close(); + }); +}); + +// ─── knockResource success path (mocked transport) ─────────────────────────── + +describe('knockResource — success path', () => { + it('returns success with resource hosts on valid ACK', async () => { + const server = generateX25519KeyPairBase64(); + const agent = new NHPAgent({ transport: 'websocket' }); + await agent.init(); + + const ackPacket = await makeAckPacket( + server.privateKey, server.publicKey, + agent.getPublicKey(), + SUCCESS_ACK + ); + + agent.setIdentity({ userId: 'user@example.com', deviceId: 'device-001' }); + agent.addServer({ publicKey: server.publicKey, host: 'nhp.example.com', port: 62206 }); + injectMockTransport(agent, ackPacket); + + const result = await agent.knockResource({ + resourceId: 'resource-1', + serviceId: 'my-service', + serverHost: 'nhp.example.com', + serverPort: 62206, + }); + + expect(result.success).toBe(true); + expect(result.resourceHosts).toEqual({ 'my-service': '10.0.0.1:8080' }); + expect(result.agentAddress).toBe('1.2.3.4:5000'); + expect(result.expiresAt).toBeGreaterThan(Date.now()); + await agent.close(); + }); + + it('expiresAt is approximately now + opnTime seconds', async () => { + const server = generateX25519KeyPairBase64(); + const agent = new NHPAgent({ transport: 'websocket' }); + await agent.init(); + + const ackPacket = await makeAckPacket( + server.privateKey, server.publicKey, + agent.getPublicKey(), + { ...SUCCESS_ACK, opnTime: 600 } + ); + + agent.setIdentity({ userId: 'u', deviceId: 'd' }); + agent.addServer({ publicKey: server.publicKey, host: 'nhp.example.com', port: 62206 }); + injectMockTransport(agent, ackPacket); + + const before = Date.now(); + const result = await agent.knockResource({ + resourceId: 'r', serviceId: 's', + serverHost: 'nhp.example.com', serverPort: 62206, + }); + + expect(result.success).toBe(true); + // expiresAt should be within 1 second of now + 600s + expect(result.expiresAt!).toBeGreaterThanOrEqual(before + 600_000 - 1000); + expect(result.expiresAt!).toBeLessThanOrEqual(before + 600_000 + 1000); + await agent.close(); + }); + + it('returns accessToken when provided in ACK', async () => { + const server = generateX25519KeyPairBase64(); + const agent = new NHPAgent({ transport: 'websocket' }); + await agent.init(); + + const ackPacket = await makeAckPacket( + server.privateKey, server.publicKey, + agent.getPublicKey(), + { ...SUCCESS_ACK, aspToken: 'tok-abc123' } + ); + + agent.setIdentity({ userId: 'u', deviceId: 'd' }); + agent.addServer({ publicKey: server.publicKey, host: 'nhp.example.com', port: 62206 }); + injectMockTransport(agent, ackPacket); + + const result = await agent.knockResource({ + resourceId: 'r', serviceId: 's', + serverHost: 'nhp.example.com', serverPort: 62206, + }); + + expect(result.success).toBe(true); + expect(result.accessToken).toBe('tok-abc123'); + await agent.close(); + }); + + it('returns errorCode 6 when server ACK contains errCode', async () => { + const server = generateX25519KeyPairBase64(); + const agent = new NHPAgent({ transport: 'websocket' }); + await agent.init(); + + const ackPacket = await makeAckPacket( + server.privateKey, server.publicKey, + agent.getPublicKey(), + { errCode: '403', errMsg: 'Access denied', resHost: {}, opnTime: 0, agentAddr: '' } + ); + + agent.setIdentity({ userId: 'u', deviceId: 'd' }); + agent.addServer({ publicKey: server.publicKey, host: 'nhp.example.com', port: 62206 }); + injectMockTransport(agent, ackPacket); + + const result = await agent.knockResource({ + resourceId: 'r', serviceId: 's', + serverHost: 'nhp.example.com', serverPort: 62206, + }); + + expect(result.success).toBe(false); + expect(result.error).toBe('Access denied'); + expect(result.errorCode).toBe(403); + await agent.close(); + }); + + it('returns errorCode 5 when transport throws', async () => { + const server = generateX25519KeyPairBase64(); + const agent = new NHPAgent({ transport: 'websocket' }); + await agent.init(); + + agent.setIdentity({ userId: 'u', deviceId: 'd' }); + agent.addServer({ publicKey: server.publicKey, host: 'nhp.example.com', port: 62206 }); + + // Mock transport that fails to connect + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.spyOn(agent as any, 'createTransport').mockReturnValue({ + connect: vi.fn().mockRejectedValue(new Error('connection refused')), + disconnect: vi.fn(), + isConnected: vi.fn().mockReturnValue(false), + send: vi.fn(), + on: vi.fn(), + off: vi.fn(), + }); + + const result = await agent.knockResource({ + resourceId: 'r', serviceId: 's', + serverHost: 'nhp.example.com', serverPort: 62206, + }); + + expect(result.success).toBe(false); + expect(result.errorCode).toBe(5); + expect(result.error).toBe('connection refused'); + await agent.close(); + }); +}); + +// ─── Event emitter ──────────────────────────────────────────────────────────── + +describe('Event emitter', () => { + it('on/off register and unregister handlers without throwing', async () => { + const agent = new NHPAgent(); + await agent.init(); + + const handler = vi.fn(); + agent.on('knock', handler); + agent.off('knock', handler); + + await agent.close(); + }); + + it('emits knock event when knockResource sends a packet', async () => { + const server = generateX25519KeyPairBase64(); + const agent = new NHPAgent({ transport: 'websocket' }); + await agent.init(); + + const ackPacket = await makeAckPacket( + server.privateKey, server.publicKey, + agent.getPublicKey(), SUCCESS_ACK + ); + + agent.setIdentity({ userId: 'u', deviceId: 'd' }); + agent.addServer({ publicKey: server.publicKey, host: 'nhp.example.com', port: 62206 }); + injectMockTransport(agent, ackPacket); + + const knockHandler = vi.fn(); + agent.on('knock', knockHandler); + + await agent.knockResource({ + resourceId: 'r', serviceId: 's', + serverHost: 'nhp.example.com', serverPort: 62206, + }); + + expect(knockHandler).toHaveBeenCalledOnce(); + await agent.close(); + }); + + it('emits ack event on successful knock', async () => { + const server = generateX25519KeyPairBase64(); + const agent = new NHPAgent({ transport: 'websocket' }); + await agent.init(); + + const ackPacket = await makeAckPacket( + server.privateKey, server.publicKey, + agent.getPublicKey(), SUCCESS_ACK + ); + + agent.setIdentity({ userId: 'u', deviceId: 'd' }); + agent.addServer({ publicKey: server.publicKey, host: 'nhp.example.com', port: 62206 }); + injectMockTransport(agent, ackPacket); + + const ackHandler = vi.fn(); + agent.on('ack', ackHandler); + + await agent.knockResource({ + resourceId: 'r', serviceId: 's', + serverHost: 'nhp.example.com', serverPort: 62206, + }); + + expect(ackHandler).toHaveBeenCalledOnce(); + await agent.close(); + }); + + it('emits error event on transport failure', async () => { + const server = generateX25519KeyPairBase64(); + const agent = new NHPAgent({ transport: 'websocket' }); + await agent.init(); + + agent.setIdentity({ userId: 'u', deviceId: 'd' }); + agent.addServer({ publicKey: server.publicKey, host: 'nhp.example.com', port: 62206 }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.spyOn(agent as any, 'createTransport').mockReturnValue({ + connect: vi.fn().mockRejectedValue(new Error('timeout')), + disconnect: vi.fn(), + isConnected: vi.fn().mockReturnValue(false), + send: vi.fn(), + on: vi.fn(), + off: vi.fn(), + }); + + const errorHandler = vi.fn(); + agent.on('error', errorHandler); + + await agent.knockResource({ + resourceId: 'r', serviceId: 's', + serverHost: 'nhp.example.com', serverPort: 62206, + }); + + expect(errorHandler).toHaveBeenCalledOnce(); + await agent.close(); + }); +}); + +// ─── exitResource ───────────────────────────────────────────────────────────── + +describe('exitResource', () => { + it('does not throw for known server', async () => { + const agent = new NHPAgent(); + await agent.init(); + agent.addServer({ publicKey: 'key', host: 'example.com', port: 62206 }); + + await expect(agent.exitResource({ + resourceId: 'r', serviceId: 's', + serverHost: 'example.com', serverPort: 62206, + })).resolves.toBeUndefined(); + + await agent.close(); + }); + + it('does not throw for unknown server', async () => { + const agent = new NHPAgent(); + await agent.init(); + + await expect(agent.exitResource({ + resourceId: 'r', serviceId: 's', + serverHost: 'unknown.example.com', serverPort: 62206, + })).resolves.toBeUndefined(); + + await agent.close(); + }); +}); + +// ─── close ──────────────────────────────────────────────────────────────────── + +describe('close', () => { + it('close is idempotent', async () => { + const agent = new NHPAgent(); + await agent.init(); + await agent.close(); + await expect(agent.close()).resolves.toBeUndefined(); + }); + + it('getPublicKey still works after close (key pair is retained)', async () => { + const agent = new NHPAgent(); + await agent.init(); + const pubKey = agent.getPublicKey(); + await agent.close(); + // close() clears transports and servers but retains the key pair + expect(agent.getPublicKey()).toBe(pubKey); + }); +}); diff --git a/nhp-js/test/protocol/header.test.ts b/nhp-js/test/protocol/header.test.ts new file mode 100644 index 0000000..5250a64 --- /dev/null +++ b/nhp-js/test/protocol/header.test.ts @@ -0,0 +1,211 @@ +/** + * Tests for NHP protocol header structures + * Validates field read/write for NHPHeader (240 bytes) and NHPHeaderEx (304 bytes) + */ + +import { describe, it, expect } from 'vitest'; +import { NHPHeader, NHPHeaderEx } from '../../src/protocol/header.js'; +import { + HEADER_SIZE, + HEADER_EX_SIZE, + PROTOCOL_VERSION, +} from '../../src/protocol/constants.js'; + +// ─── NHPHeader (curve25519, 240 bytes) ─────────────────────────────────────── + +describe('NHPHeader (curve25519, 240 bytes)', () => { + function makeHeader() { + const buf = new ArrayBuffer(HEADER_SIZE); + return new NHPHeader(buf); + } + + it('size should be 240', () => { + expect(makeHeader().size).toBe(240); + }); + + it('version round-trips correctly', () => { + const h = makeHeader(); + h.version = { major: PROTOCOL_VERSION.MAJOR, minor: PROTOCOL_VERSION.MINOR }; + expect(h.version).toEqual({ major: 1, minor: 0 }); + }); + + it('flags: extended=false, compressed=false', () => { + const h = makeHeader(); + h.flags = { extended: false, compressed: false }; + expect(h.flags).toEqual({ extended: false, compressed: false }); + }); + + it('flags: compressed=true sets bit correctly', () => { + const h = makeHeader(); + h.flags = { extended: false, compressed: true }; + expect(h.flags.compressed).toBe(true); + expect(h.flags.extended).toBe(false); + }); + + it('flags: extended=true sets bit correctly', () => { + const h = makeHeader(); + h.flags = { extended: true, compressed: false }; + expect(h.flags.extended).toBe(true); + expect(h.flags.compressed).toBe(false); + }); + + it('counter round-trips correctly', () => { + const h = makeHeader(); + h.counter = 42n; + expect(h.counter).toBe(42n); + }); + + it('counter handles large values', () => { + const h = makeHeader(); + const big = 0xdeadbeefcafebaben; + h.counter = big; + expect(h.counter).toBe(big); + }); + + it('nonce is 12 bytes derived from counter', () => { + const h = makeHeader(); + h.counter = 1n; + const nonce = h.nonce; + expect(nonce.length).toBe(12); + // First 4 bytes should be zero + expect(nonce[0]).toBe(0); + expect(nonce[1]).toBe(0); + expect(nonce[2]).toBe(0); + expect(nonce[3]).toBe(0); + // Last 8 bytes encode the counter (big-endian), counter=1 → last byte = 1 + expect(nonce[11]).toBe(1); + }); + + it('ephemeral key round-trips (32 bytes)', () => { + const h = makeHeader(); + const key = new Uint8Array(32).fill(0xab); + h.ephemeral = key; + expect(h.ephemeral).toEqual(key); + }); + + it('ephemeral setter ignores wrong-length input', () => { + const h = makeHeader(); + const before = new Uint8Array(h.ephemeral); + h.ephemeral = new Uint8Array(16); // wrong length + expect(h.ephemeral).toEqual(before); + }); + + it('static field round-trips (48 bytes)', () => { + const h = makeHeader(); + const val = new Uint8Array(48).fill(0xcd); + h.static = val; + expect(h.static).toEqual(val); + }); + + it('timestamp field round-trips (24 bytes)', () => { + const h = makeHeader(); + const val = new Uint8Array(24).fill(0x12); + h.timestamp = val; + expect(h.timestamp).toEqual(val); + }); + + it('hmac field round-trips (32 bytes)', () => { + const h = makeHeader(); + const val = new Uint8Array(32).fill(0xff); + h.hmac = val; + expect(h.hmac).toEqual(val); + }); + + it('typeAndPayloadSize encodes type and size with random preamble', () => { + const h = makeHeader(); + h.typeAndPayloadSize = { type: 1, size: 100 }; + const { type, size } = h.typeAndPayloadSize; + expect(type).toBe(1); + expect(size).toBe(100); + }); + + it('typeAndPayloadSize produces different bytes on each write (random preamble)', () => { + const buf = new ArrayBuffer(HEADER_SIZE); + const h = new NHPHeader(buf); + + h.typeAndPayloadSize = { type: 2, size: 50 }; + const bytes1 = new Uint8Array(buf.slice(0, 8)); + + h.typeAndPayloadSize = { type: 2, size: 50 }; + const bytes2 = new Uint8Array(buf.slice(0, 8)); + + // Preamble is random so raw bytes should differ (with overwhelming probability) + expect(bytes1).not.toEqual(bytes2); + // But decoded values must be identical + expect(h.typeAndPayloadSize).toEqual({ type: 2, size: 50 }); + }); + + it('fields do not overlap (write one, others unchanged)', () => { + const h = makeHeader(); + h.hmac = new Uint8Array(32).fill(0xaa); + h.ephemeral = new Uint8Array(32).fill(0xbb); + // hmac should still be 0xaa + expect(Array.from(h.hmac).every(b => b === 0xaa)).toBe(true); + }); +}); + +// ─── NHPHeaderEx (gmsm, 304 bytes) ─────────────────────────────────────────── + +describe('NHPHeaderEx (gmsm, 304 bytes)', () => { + function makeHeaderEx() { + const buf = new ArrayBuffer(HEADER_EX_SIZE); + return new NHPHeaderEx(buf); + } + + it('size should be 304', () => { + expect(makeHeaderEx().size).toBe(304); + }); + + it('version round-trips correctly', () => { + const h = makeHeaderEx(); + h.version = { major: 1, minor: 0 }; + expect(h.version).toEqual({ major: 1, minor: 0 }); + }); + + it('flags: extended=true for GM packets', () => { + const h = makeHeaderEx(); + h.flags = { extended: true, compressed: true }; + expect(h.flags).toEqual({ extended: true, compressed: true }); + }); + + it('counter round-trips correctly', () => { + const h = makeHeaderEx(); + h.counter = 999n; + expect(h.counter).toBe(999n); + }); + + it('nonce is 12 bytes derived from counter', () => { + const h = makeHeaderEx(); + h.counter = 2n; + const nonce = h.nonce; + expect(nonce.length).toBe(12); + expect(nonce[11]).toBe(2); + }); + + it('ephemeral key round-trips (64 bytes for SM2)', () => { + const h = makeHeaderEx(); + const key = new Uint8Array(64).fill(0x77); + h.ephemeral = key; + expect(h.ephemeral).toEqual(key); + }); + + it('static field round-trips (80 bytes for SM2)', () => { + const h = makeHeaderEx(); + const val = new Uint8Array(80).fill(0x33); + h.static = val; + expect(h.static).toEqual(val); + }); + + it('hmac field round-trips (32 bytes)', () => { + const h = makeHeaderEx(); + const val = new Uint8Array(32).fill(0x55); + h.hmac = val; + expect(h.hmac).toEqual(val); + }); + + it('typeAndPayloadSize round-trips', () => { + const h = makeHeaderEx(); + h.typeAndPayloadSize = { type: 7, size: 200 }; + expect(h.typeAndPayloadSize).toEqual({ type: 7, size: 200 }); + }); +}); diff --git a/nhp-js/test/protocol/packet.test.ts b/nhp-js/test/protocol/packet.test.ts new file mode 100644 index 0000000..539f847 --- /dev/null +++ b/nhp-js/test/protocol/packet.test.ts @@ -0,0 +1,401 @@ +/** + * Tests for NHP packet build/parse round-trips + * Covers: ACK round-trip, COK/RNK cookie flow, GMSM scheme, + * compression toggle, error cases, and anti-replay checks + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { + buildNHPPacket, + parseNHPPacket, + clearServerCookie, + resetGlobalCounter, +} from '../../src/protocol/packet.js'; +import { NHP_PACKET_TYPES } from '../../src/protocol/constants.js'; +import { generateX25519KeyPairBase64 } from '../../src/crypto/ecdh.js'; +import { generateSM2KeyPairBase64 } from '../../src/crypto/sm2.js'; + +// ─── helpers ───────────────────────────────────────────────────────────────── + +function makeKeyPairs() { + const agent = generateX25519KeyPairBase64(); + const server = generateX25519KeyPairBase64(); + return { agent, server }; +} + +function makeGMKeyPairs() { + const agent = generateSM2KeyPairBase64(); + const server = generateSM2KeyPairBase64(); + return { agent, server }; +} + +// Reset counter before each test so tests are independent +beforeEach(() => { + resetGlobalCounter(); +}); + +// ─── CURVE25519 ACK round-trip ──────────────────────────────────────────────── + +describe('CURVE25519 ACK round-trip', () => { + it('builds and parses a basic ACK packet', async () => { + const { agent, server } = makeKeyPairs(); + const msg = 'hello NHP'; + + const packet = await buildNHPPacket( + NHP_PACKET_TYPES.ACK, + server.privateKey, server.publicKey, + agent.publicKey, + msg, false, 'curve25519' + ); + + const parsed = await parseNHPPacket( + packet, + agent.privateKey, agent.publicKey, + server.publicKey + ); + + expect(parsed.type).toBe(NHP_PACKET_TYPES.ACK); + expect(parsed.message).toBe(msg); + expect(parsed.remotePublicKey).toBe(server.publicKey); + }); + + it('builds and parses with compression enabled', async () => { + const { agent, server } = makeKeyPairs(); + const msg = JSON.stringify({ errCode: '', resHost: { demo: '10.0.0.1:8080' }, opnTime: 300, agentAddr: '1.2.3.4:5678' }); + + const packet = await buildNHPPacket( + NHP_PACKET_TYPES.ACK, + server.privateKey, server.publicKey, + agent.publicKey, + msg, true, 'curve25519' + ); + + const parsed = await parseNHPPacket( + packet, + agent.privateKey, agent.publicKey, + server.publicKey + ); + + expect(parsed.type).toBe(NHP_PACKET_TYPES.ACK); + expect(parsed.message).toBe(msg); + }); + + it('compressed packet is smaller than uncompressed for repetitive payload', async () => { + const { agent, server } = makeKeyPairs(); + // Repetitive JSON compresses well + const msg = JSON.stringify({ data: 'aaaa'.repeat(100) }); + + const compressed = await buildNHPPacket( + NHP_PACKET_TYPES.ACK, + server.privateKey, server.publicKey, + agent.publicKey, + msg, true, 'curve25519' + ); + const uncompressed = await buildNHPPacket( + NHP_PACKET_TYPES.ACK, + server.privateKey, server.publicKey, + agent.publicKey, + msg, false, 'curve25519' + ); + + expect(compressed.length).toBeLessThan(uncompressed.length); + }); + + it('auto-detects curve25519 scheme when cipherScheme is omitted', async () => { + const { agent, server } = makeKeyPairs(); + const msg = 'auto detect'; + + const packet = await buildNHPPacket( + NHP_PACKET_TYPES.ACK, + server.privateKey, server.publicKey, + agent.publicKey, + msg, false + ); + + const parsed = await parseNHPPacket( + packet, + agent.privateKey, agent.publicKey, + server.publicKey + ); + + expect(parsed.message).toBe(msg); + }); + + it('packet length equals header size + payload size', async () => { + const { agent, server } = makeKeyPairs(); + const msg = 'size check'; + + const packet = await buildNHPPacket( + NHP_PACKET_TYPES.ACK, + server.privateKey, server.publicKey, + agent.publicKey, + msg, false, 'curve25519' + ); + + // Standard header is 240 bytes; payload = encrypted message + 16-byte tag + expect(packet.length).toBeGreaterThan(240); + }); +}); + +// ─── COK / RNK cookie flow ──────────────────────────────────────────────────── + +describe('COK / RNK cookie flow', () => { + it('COK packet is stored and used in subsequent RNK', async () => { + const { agent, server } = makeKeyPairs(); + const cookie = new Uint8Array(32).fill(0xc0); + + // Server sends COK with cookie payload + const cookiePacket = await buildNHPPacket( + NHP_PACKET_TYPES.COK, + server.privateKey, server.publicKey, + agent.publicKey, + String.fromCharCode(...cookie), false, 'curve25519' + ); + + // Agent parses COK — cookie is stored internally keyed by server public key + const parsedCok = await parseNHPPacket( + cookiePacket, + agent.privateKey, agent.publicKey, + server.publicKey + ); + expect(parsedCok.type).toBe(NHP_PACKET_TYPES.COK); + + // Agent sends RNK using the stored cookie in HMAC + const rnkPacket = await buildNHPPacket( + NHP_PACKET_TYPES.RNK, + agent.privateKey, agent.publicKey, + server.publicKey, + 're-knock payload', false, 'curve25519' + ); + + // RNK packet should be built successfully + expect(rnkPacket.length).toBeGreaterThan(240); + }); +}); + +// ─── Error cases ───────────────────────────────────────────────────────────── + +describe('Error cases', () => { + it('throws on packet too small to be valid', async () => { + const { agent, server } = makeKeyPairs(); + + await expect( + parseNHPPacket(new Uint8Array(10), agent.privateKey, agent.publicKey, server.publicKey) + ).rejects.toThrow('Packet size is too small'); + }); + + it('throws when parsing a KNK packet (not ACK/COK)', async () => { + const { agent, server } = makeKeyPairs(); + + const packet = await buildNHPPacket( + NHP_PACKET_TYPES.KNK, + agent.privateKey, agent.publicKey, + server.publicKey, + 'knock', false, 'curve25519' + ); + + await expect( + parseNHPPacket(packet, server.privateKey, server.publicKey, agent.publicKey) + ).rejects.toThrow('Not an ACK or COK packet'); + }); + + it('throws on HMAC tampering', async () => { + const { agent, server } = makeKeyPairs(); + + const packet = await buildNHPPacket( + NHP_PACKET_TYPES.ACK, + server.privateKey, server.publicKey, + agent.publicKey, + 'tamper test', false, 'curve25519' + ); + + // Corrupt the last 8 bytes of the header (HMAC region) + const tampered = new Uint8Array(packet); + tampered[232] ^= 0xff; + + await expect( + parseNHPPacket(tampered, agent.privateKey, agent.publicKey, server.publicKey) + ).rejects.toThrow(); + }); + + it('throws when wrong private key used for decryption', async () => { + const { agent, server } = makeKeyPairs(); + const { agent: wrongAgent } = makeKeyPairs(); + + const packet = await buildNHPPacket( + NHP_PACKET_TYPES.ACK, + server.privateKey, server.publicKey, + agent.publicKey, + 'secret', false, 'curve25519' + ); + + await expect( + parseNHPPacket(packet, wrongAgent.privateKey, wrongAgent.publicKey, server.publicKey) + ).rejects.toThrow(); + }); + + it('throws when wrong server public key used', async () => { + const { agent, server } = makeKeyPairs(); + const { server: wrongServer } = makeKeyPairs(); + + const packet = await buildNHPPacket( + NHP_PACKET_TYPES.ACK, + server.privateKey, server.publicKey, + agent.publicKey, + 'secret', false, 'curve25519' + ); + + await expect( + parseNHPPacket(packet, agent.privateKey, agent.publicKey, wrongServer.publicKey) + ).rejects.toThrow(); + }); +}); + +// ─── Anti-replay ───────────────────────────────────────────────────────────── + +describe('Anti-replay protection', () => { + it('rejects a replayed packet (same packet sent twice)', async () => { + const { agent, server } = makeKeyPairs(); + + const packet = await buildNHPPacket( + NHP_PACKET_TYPES.ACK, + server.privateKey, server.publicKey, + agent.publicKey, + 'replay test', false, 'curve25519' + ); + + // First parse succeeds + await expect( + parseNHPPacket(packet, agent.privateKey, agent.publicKey, server.publicKey) + ).resolves.toBeDefined(); + + // Replaying the exact same packet must be rejected + await expect( + parseNHPPacket(packet, agent.privateKey, agent.publicKey, server.publicKey) + ).rejects.toThrow(); + }); +}); + +// ─── clearServerCookie ──────────────────────────────────────────────────────── + +describe('clearServerCookie', () => { + it('does not throw when called for unknown key', () => { + expect(() => clearServerCookie('nonexistent-key')).not.toThrow(); + }); + + it('clears stored cookie so RNK no longer includes it', async () => { + const { agent, server } = makeKeyPairs(); + + // Store a cookie by parsing a COK packet + const cookiePacket = await buildNHPPacket( + NHP_PACKET_TYPES.COK, + server.privateKey, server.publicKey, + agent.publicKey, + 'cookie-data', false, 'curve25519' + ); + await parseNHPPacket( + cookiePacket, + agent.privateKey, agent.publicKey, + server.publicKey + ); + + // Clear it + clearServerCookie(server.publicKey); + + // RNK can still be built (just without cookie in HMAC) — should not throw + const rnkPacket = await buildNHPPacket( + NHP_PACKET_TYPES.RNK, + agent.privateKey, agent.publicKey, + server.publicKey, + 'rnk after clear', false, 'curve25519' + ); + expect(rnkPacket.length).toBeGreaterThan(0); + }); +}); + +// ─── GMSM (SM2/SM3/SM4) round-trip ─────────────────────────────────────────── + +describe('GMSM ACK round-trip', () => { + it('builds and parses a GMSM ACK packet', async () => { + const { agent, server } = makeGMKeyPairs(); + const msg = 'gmsm test payload'; + + const packet = await buildNHPPacket( + NHP_PACKET_TYPES.ACK, + server.privateKey, server.publicKey, + agent.publicKey, + msg, false, 'gmsm' + ); + + const parsed = await parseNHPPacket( + packet, + agent.privateKey, agent.publicKey, + server.publicKey + ); + + expect(parsed.type).toBe(NHP_PACKET_TYPES.ACK); + expect(parsed.message).toBe(msg); + }); + + it('GMSM packet is larger than curve25519 (304 vs 240 byte header)', async () => { + const gm = makeGMKeyPairs(); + const c25519 = makeKeyPairs(); + const msg = 'size comparison'; + + const gmPacket = await buildNHPPacket( + NHP_PACKET_TYPES.ACK, + gm.server.privateKey, gm.server.publicKey, + gm.agent.publicKey, + msg, false, 'gmsm' + ); + const c25519Packet = await buildNHPPacket( + NHP_PACKET_TYPES.ACK, + c25519.server.privateKey, c25519.server.publicKey, + c25519.agent.publicKey, + msg, false, 'curve25519' + ); + + expect(gmPacket.length).toBeGreaterThan(c25519Packet.length); + }); + + it('auto-detects gmsm scheme from SM2 key length', async () => { + const { agent, server } = makeGMKeyPairs(); + const msg = 'auto gmsm'; + + // Pass no cipherScheme — should auto-detect gmsm from key length > 50 chars + const packet = await buildNHPPacket( + NHP_PACKET_TYPES.ACK, + server.privateKey, server.publicKey, + agent.publicKey, + msg, false + ); + + const parsed = await parseNHPPacket( + packet, + agent.privateKey, agent.publicKey, + server.publicKey + ); + + expect(parsed.message).toBe(msg); + }); + + it('builds and parses GMSM with compression', async () => { + const { agent, server } = makeGMKeyPairs(); + const msg = JSON.stringify({ data: 'repeat'.repeat(50) }); + + const packet = await buildNHPPacket( + NHP_PACKET_TYPES.ACK, + server.privateKey, server.publicKey, + agent.publicKey, + msg, true, 'gmsm' + ); + + const parsed = await parseNHPPacket( + packet, + agent.privateKey, agent.publicKey, + server.publicKey + ); + + expect(parsed.message).toBe(msg); + }); +});