From d712b4d4a138f941a5f1a4c6cf9bebebb58cab18 Mon Sep 17 00:00:00 2001 From: Boot Date: Wed, 5 Aug 2026 15:30:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20v0.2.0=20=E2=80=94=20CLI,=20version=20c?= =?UTF-8?q?heck,=20keygen/register=20tests,=20pathPrefix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four hardening items for customer delivery: 1. VERSION export + CLI --version + upgrade --check against GitHub Releases (exit codes 0/10/20, graceful degradation) 2. CLI bin (agentgate): keygen, register, keys list, keys revoke with security rules (ops token from env only, private key never printed, 0600 perms) 3. keygen + register unit tests (node:test, mock fetch, no live network) — 52 new tests 4. pathPrefix on signRequest/AgentSigner/createSigner: signature covers the internal path after proxy prefix stripping, eliminating silent 401 errors 135 tests total, all passing. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 31 +++++++ README.md | 49 ++++++++--- SKILL.md | 50 ++++++++--- package.json | 8 +- src/cli.js | 198 ++++++++++++++++++++++++++++++++++++++++++ src/index.js | 1 + src/lib/client.js | 9 +- src/lib/register.js | 15 ++-- src/lib/signing.js | 9 +- src/lib/version.js | 67 ++++++++++++++ test/client.test.js | 142 ++++++++++++++++++++++++++++++ test/keygen.test.js | 154 ++++++++++++++++++++++++++++++++ test/register.test.js | 164 ++++++++++++++++++++++++++++++++++ 13 files changed, 861 insertions(+), 36 deletions(-) create mode 100755 src/cli.js create mode 100644 src/lib/version.js create mode 100644 test/client.test.js create mode 100644 test/keygen.test.js create mode 100644 test/register.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index ea38c88..9970ace 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ # Changelog +## [0.2.0] - 2026-08-05 + +### Added +- **CLI** (`agentgate` bin): `--version`, `upgrade --check [--json]`, `keygen`, + `register`, `keys list`, `keys revoke` +- **Version check**: `agentgate upgrade --check` queries GitHub Releases for the + latest version; exit codes `0` (up to date), `10` (upgrade available), + `20` (check failed); graceful degradation on network errors, rate limits, and + missing releases +- **`VERSION` export** from `src/index.js`, read from `package.json` (single + source of truth) +- **`pathPrefix`** option on `signRequest`, `AgentSigner`, and `createSigner` — + when a reverse proxy strips a path prefix (e.g. `/ajj/agent`), the signature + covers the internal path, not the public URL. Eliminates silent 401 errors + that every customer agent would otherwise hit +- **keygen tests** (`test/keygen.test.js`): key type, base64 export round-trip, + fingerprint, save/load round-trip, 0600 permission enforcement, seed + determinism, path sanitization +- **register tests** (`test/register.test.js`): URL construction, body shape + (`kid`/`alg`/`pubkey_b64` only), `alg` = `"Ed25519"`, error code mapping + (only 500 retryable), `request_id` pass-through, revoke/list operations +- **pathPrefix tests** (`test/client.test.js`): signed path ≠ requested path, + prefix stripping, fallback to `/`, non-matching prefix is no-op + +### Changed +- `signRequest` accepts optional `pathPrefix` parameter +- `AgentSigner` constructor accepts optional `pathPrefix` +- `createSigner` reads `path_prefix` from config +- `registerKey`, `revokeKey`, `listKeys` accept optional `_fetch` for + dependency injection in tests + ## [0.1.0] - 2026-08-04 ### Added diff --git a/README.md b/README.md index 0c7860a..9c1cb55 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,23 @@ Manages Ed25519 key pairs (keygen, registration, rotation) and signs outgoing HTTP requests with RFC-9421-shaped signatures that are byte-identical to the Go `agentsig` verifier in `platform/internal/agentsig`. -## Status +## CLI -**Skeleton** — signing core implemented and verified against 15 golden vectors. -Key management, registration, and rotation are scaffolded but not yet end-to-end -tested. +```bash +agentgate --version # installed version +agentgate upgrade --check [--json] # check for upgrades (GitHub Releases) +agentgate keygen --kid [--out ] +agentgate register --kid --tenant --agent --cp-url +agentgate keys list --tenant --agent --cp-url +agentgate keys revoke --kid --tenant --agent --cp-url +``` + +**Security:** the ops token is read from the `AGENTGATE_OPS_TOKEN` environment +variable, never from CLI arguments (avoids shell history / `ps` leaks). Private +keys are never printed, logged, or uploaded. + +**Exit codes** (`upgrade --check`): `0` = up to date, `10` = upgrade available, +`20` = check failed. ## Architecture @@ -20,10 +32,12 @@ the agent runtime. ``` openmax-agentgate/ +├── src/cli.js # CLI entry point (agentgate bin) ├── src/lib/signing.js # Signing base + Ed25519 signing (byte-identical to Go) ├── src/lib/keygen.js # Ed25519 key generation, PEM load/save ├── src/lib/register.js # Control plane key registration API ├── src/lib/client.js # AgentSigner class (high-level signing client) +├── src/lib/version.js # Version + upgrade check (GitHub Releases) ├── src/lib/config.js # Config loader with hot-reload └── src/index.js # PM2 service entry point ``` @@ -38,29 +52,38 @@ const signer = createSigner({ tenant_id: 'my-tenant', agent_id: 'my-agent', key_id: 'my-agent-2026a', + path_prefix: '/ajj/agent', // prefix the reverse proxy strips }); -const headers = signer.sign('POST', 'https://cp.example/api/v1/query', body); +const headers = signer.sign('POST', 'https://gateway.example/ajj/agent/api/v1/query', body); +// Signature covers /api/v1/query (internal path), not the full public URL. // headers: Content-Digest, X-Openmax-Tenant, X-Openmax-Agent, // Signature-Input, Signature ``` +## Signing Path Prefix + +When a reverse proxy (e.g. Caddy) strips a path prefix before forwarding to +the control plane, the signature must cover the **internal** path (after +stripping), not the public URL. Set `path_prefix` (config) or `pathPrefix` +(constructor) to the prefix being stripped (e.g. `"/ajj/agent"`). The client +signs the stripped path automatically while still sending requests to the full +public URL. + +Without this, every request silently gets `401` with no hint — the signed path +does not match what the control plane sees. + ## Testing ```bash -node --test test/signing.test.js +npm test ``` -Runs 83 subtests against the frozen golden vectors from -`platform/internal/agentsig/testdata/vectors.json`. +Runs 135 tests: golden vectors (signing), keygen, register (mock fetch), and +pathPrefix verification. ## Path Encoding Node's `new URL().pathname` and Go's `url.URL.EscapedPath()` diverge on two characters: `|` (→ `%7C`) and `^` (→ `%5E`). The `encodePath()` function supplements Node's pathname by encoding only those two. - -It does NOT replicate Go's `validEncoded` two-branch fallback. In production -(Model B: Node puts pathname on the wire, Go parses the wire string), Node's -pathname encodes backtick/braces before they reach Go, so Go receives a string -where all chars pass `validEncoded` — the conditional bracket cascade never fires. diff --git a/SKILL.md b/SKILL.md index d165234..b0dbe16 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,13 +1,14 @@ --- name: agentgate -version: 0.1.0 +version: 0.2.0 description: > Agent identity and per-request Ed25519 signing for zylos agents. Manages Ed25519 key pairs (keygen, registration, rotation) and signs outgoing HTTP requests with RFC-9421-shaped signatures that are byte-identical to the Go agentsig verifier. Use when: an agent needs to authenticate requests to the control plane, manage agent identity keys, sign API requests, rotate keys - (make-before-break), or register public keys with the admin plane. + (make-before-break), register public keys with the admin plane, or check + for available upgrades. type: capability lifecycle: @@ -45,24 +46,49 @@ config: - name: AGENTGATE_KEY_ID description: Key identifier (auto-generated if not set) default: "" + - name: AGENTGATE_OPS_TOKEN + description: Admin/ops token for register, keys list, keys revoke (read from env, never CLI args) + sensitive: true + - name: AGENTGATE_PATH_PREFIX + description: Path prefix the reverse proxy strips (e.g. "/ajj/agent") — signature covers the internal path + default: "" dependencies: [] --- # Agentgate +## CLI + ```bash -# Sign a request (library usage from another component) -import { createSigner } from 'openmax-agentgate'; -const signer = await createSigner({ configPath: '...' }); -const headers = await signer.sign('POST', '/api/v1/query', body); +agentgate --version # Print installed version +agentgate upgrade --check [--json] # Check for upgrades via GitHub Releases +agentgate keygen --kid [--out ] # Generate Ed25519 key pair +agentgate register --kid --tenant --agent --cp-url +agentgate keys list --tenant --agent --cp-url +agentgate keys revoke --kid --tenant --agent --cp-url +``` + +Ops token: set `AGENTGATE_OPS_TOKEN` env var (never pass as CLI argument). -# Generate a new key pair -node src/cli.js keygen +## Library -# Register public key with control plane -node src/cli.js register +```js +import { createSigner } from 'openmax-agentgate/client'; -# Rotate keys (make-before-break) -node src/cli.js rotate +const signer = createSigner({ + private_key_path: '/path/to/key.pem', + tenant_id: 'my-tenant', + agent_id: 'my-agent', + key_id: 'my-agent-2026a', + path_prefix: '/ajj/agent', +}); + +const headers = signer.sign('POST', 'https://gw.example/ajj/agent/api/v1/query', body); ``` + +## Signing Path Prefix + +When a reverse proxy strips `/ajj/agent` before forwarding, set `path_prefix` +so the signature covers the internal path. Without this, requests get silent +401 errors. diff --git a/package.json b/package.json index 6b02f32..dddd124 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,18 @@ { "name": "openmax-agentgate", - "version": "0.1.0", + "version": "0.2.0", "description": "Agent identity and per-request Ed25519 signing for zylos agents", "type": "module", "main": "src/index.js", + "bin": { + "agentgate": "src/cli.js" + }, "exports": { ".": "./src/index.js", "./signing": "./src/lib/signing.js", "./keygen": "./src/lib/keygen.js", - "./client": "./src/lib/client.js" + "./client": "./src/lib/client.js", + "./version": "./src/lib/version.js" }, "scripts": { "start": "node src/index.js", diff --git a/src/cli.js b/src/cli.js new file mode 100755 index 0000000..c7c636c --- /dev/null +++ b/src/cli.js @@ -0,0 +1,198 @@ +#!/usr/bin/env node +import { VERSION, checkUpgrade } from './lib/version.js'; +import { generateKeyPair, exportPublicKeyBase64, publicKeyFingerprint, savePrivateKey, defaultKeyPath } from './lib/keygen.js'; +import { registerKey, revokeKey, listKeys } from './lib/register.js'; +import crypto from 'node:crypto'; + +const args = process.argv.slice(2); +const command = args[0] || ''; + +function flag(name) { + const idx = args.indexOf(name); + if (idx === -1) return undefined; + return args[idx + 1]; +} + +function hasFlag(name) { + return args.includes(name); +} + +function requiredFlag(name, label) { + const val = flag(name); + if (!val) { + console.error(`Error: ${label || name} is required`); + process.exit(1); + } + return val; +} + +function getOpsToken() { + const token = process.env.AGENTGATE_OPS_TOKEN; + if (!token) { + console.error('Error: AGENTGATE_OPS_TOKEN environment variable is required'); + console.error('Set it before running this command (never pass tokens as CLI arguments)'); + process.exit(1); + } + return token; +} + +async function main() { + if (command === 'version' || command === '--version' || command === '-v') { + console.log(VERSION); + return; + } + + if (command === 'upgrade') { + if (!hasFlag('--check')) { + console.error('Usage: agentgate upgrade --check [--json]'); + process.exit(1); + } + const result = await checkUpgrade(); + const json = hasFlag('--json'); + + if (json) { + const output = { current: result.current, latest: result.latest, upgrade_available: result.upgrade_available, source: result.source }; + if (result.error) output.error = result.error; + console.log(JSON.stringify(output)); + } else { + if (result.error) { + console.error(`Upgrade check failed: ${result.error}`); + console.error(`Installed: ${result.current}`); + } else if (result.upgrade_available) { + console.log(`Upgrade available: ${result.current} → ${result.latest}`); + console.log('Run: npm install openmax-agentgate@latest'); + } else { + console.log(`Up to date: ${result.current}`); + } + } + + if (result.error) process.exit(20); + if (result.upgrade_available) process.exit(10); + process.exit(0); + } + + if (command === 'keygen') { + const kid = requiredFlag('--kid', '--kid '); + const outPath = flag('--out') || defaultKeyPath(kid); + + const { publicKey, privateKey } = generateKeyPair(); + savePrivateKey(privateKey, outPath); + + const pubB64 = exportPublicKeyBase64(publicKey); + const fp = publicKeyFingerprint(publicKey); + + console.log(`Key pair generated.`); + console.log(` kid: ${kid}`); + console.log(` public key: ${pubB64}`); + console.log(` fingerprint: ${fp}`); + console.log(` private key: ${outPath} (0600)`); + return; + } + + if (command === 'register') { + const kid = requiredFlag('--kid', '--kid '); + const tenant = requiredFlag('--tenant', '--tenant '); + const agent = requiredFlag('--agent', '--agent '); + const cpUrl = requiredFlag('--cp-url', '--cp-url '); + const opsToken = getOpsToken(); + const keyPath = flag('--key') || defaultKeyPath(kid); + + const { loadPrivateKey } = await import('./lib/keygen.js'); + const pk = loadPrivateKey(keyPath); + const publicKey = crypto.createPublicKey(pk); + + const result = await registerKey({ + controlPlaneUrl: cpUrl, + tenantId: tenant, + agentId: agent, + kid, + publicKey, + opsToken, + }); + + console.log(`Key registered.`); + console.log(` kid: ${result.kid}`); + console.log(` fingerprint: ${result.fingerprint}`); + console.log(` registered: ${result.registered_at}`); + return; + } + + if (command === 'keys') { + const subCmd = args[1] || ''; + + if (subCmd === 'list') { + const tenant = requiredFlag('--tenant', '--tenant '); + const agent = requiredFlag('--agent', '--agent '); + const cpUrl = requiredFlag('--cp-url', '--cp-url '); + const opsToken = getOpsToken(); + + const keys = await listKeys({ + controlPlaneUrl: cpUrl, + tenantId: tenant, + agentId: agent, + opsToken, + }); + + if (keys.length === 0) { + console.log('No keys registered.'); + } else { + for (const k of keys) { + console.log(` ${k.kid} ${k.fingerprint} ${k.registered_at}`); + } + } + return; + } + + if (subCmd === 'revoke') { + const kid = requiredFlag('--kid', '--kid '); + const tenant = requiredFlag('--tenant', '--tenant '); + const agent = requiredFlag('--agent', '--agent '); + const cpUrl = requiredFlag('--cp-url', '--cp-url '); + const opsToken = getOpsToken(); + + const result = await revokeKey({ + controlPlaneUrl: cpUrl, + tenantId: tenant, + agentId: agent, + kid, + opsToken, + }); + + console.log(`Key revoked: ${kid}`); + return; + } + + console.error('Usage: agentgate keys [options]'); + process.exit(1); + } + + // Help / unknown command + console.log(`agentgate v${VERSION} + +Usage: + agentgate --version Print installed version + agentgate version Print installed version + agentgate upgrade --check [--json] Check for upgrades (GitHub Releases) + agentgate keygen --kid [--out ] + Generate Ed25519 key pair + agentgate register --kid --tenant --agent --cp-url + Register public key with control plane + agentgate keys list --tenant --agent --cp-url + List registered keys + agentgate keys revoke --kid --tenant --agent --cp-url + Revoke a key + +Environment: + AGENTGATE_OPS_TOKEN Admin/ops token for register, keys list, keys revoke + (never pass tokens as CLI arguments) + +Exit codes (upgrade --check): + 0 Up to date + 10 Upgrade available + 20 Check failed (network error, rate limit, no releases)`); +} + +main().catch(err => { + console.error(err.message || err); + process.exit(1); +}); diff --git a/src/index.js b/src/index.js index b3a7904..7e2630e 100644 --- a/src/index.js +++ b/src/index.js @@ -61,3 +61,4 @@ export { signRequest, signIngestRequest, contentDigest, signingBase, signingBase export { generateKeyPair, exportPublicKeyBase64, publicKeyFingerprint, savePrivateKey, loadPrivateKey, privateKeyFromSeed } from './lib/keygen.js'; export { registerKey, revokeKey, listKeys } from './lib/register.js'; export { createSigner } from './lib/client.js'; +export { VERSION } from './lib/version.js'; diff --git a/src/lib/client.js b/src/lib/client.js index c932f03..0062417 100644 --- a/src/lib/client.js +++ b/src/lib/client.js @@ -14,6 +14,7 @@ export class AgentSigner { #tenantId; #agentId; #kid; + #pathPrefix; /** * @param {object} opts @@ -21,8 +22,9 @@ export class AgentSigner { * @param {string} opts.tenantId * @param {string} opts.agentId * @param {string} opts.kid - key identifier + * @param {string} [opts.pathPrefix] - prefix the reverse proxy strips (e.g. "/ajj/agent") */ - constructor({ privateKey, tenantId, agentId, kid }) { + constructor({ privateKey, tenantId, agentId, kid, pathPrefix }) { if (!privateKey || privateKey.asymmetricKeyType !== 'ed25519') { throw new Error('AgentSigner requires an Ed25519 private key'); } @@ -33,6 +35,7 @@ export class AgentSigner { this.#tenantId = tenantId; this.#agentId = agentId; this.#kid = kid; + this.#pathPrefix = pathPrefix || ''; } /** @@ -52,12 +55,14 @@ export class AgentSigner { tenantId: this.#tenantId, agentId: this.#agentId, kid: this.#kid, + pathPrefix: this.#pathPrefix, }); } get tenantId() { return this.#tenantId; } get agentId() { return this.#agentId; } get kid() { return this.#kid; } + get pathPrefix() { return this.#pathPrefix; } } /** @@ -68,6 +73,7 @@ export class AgentSigner { * @param {string} config.tenant_id * @param {string} config.agent_id * @param {string} config.key_id + * @param {string} [config.path_prefix] - prefix the reverse proxy strips (e.g. "/ajj/agent") * @returns {AgentSigner} */ export function createSigner(config) { @@ -77,5 +83,6 @@ export function createSigner(config) { tenantId: config.tenant_id, agentId: config.agent_id, kid: config.key_id, + pathPrefix: config.path_prefix, }); } diff --git a/src/lib/register.js b/src/lib/register.js index 78d3ff0..aeb522f 100644 --- a/src/lib/register.js +++ b/src/lib/register.js @@ -68,13 +68,14 @@ async function parseErrorResponse(res) { * @returns {Promise} registration response * @throws {AdminPlaneError} */ -export async function registerKey({ controlPlaneUrl, tenantId, agentId, kid, publicKey, opsToken }) { +export async function registerKey({ controlPlaneUrl, tenantId, agentId, kid, publicKey, opsToken, _fetch }) { + const fetchFn = _fetch || fetch; const url = `${controlPlaneUrl}/admin/v1/tenants/${encodeURIComponent(tenantId)}/agents/${encodeURIComponent(agentId)}/keys`; const headers = { 'Content-Type': 'application/json' }; if (opsToken) headers['Authorization'] = `Bearer ${opsToken}`; - const res = await fetch(url, { + const res = await fetchFn(url, { method: 'POST', headers, body: JSON.stringify({ @@ -111,13 +112,14 @@ export async function registerKey({ controlPlaneUrl, tenantId, agentId, kid, pub * @returns {Promise} revocation response * @throws {AdminPlaneError} */ -export async function revokeKey({ controlPlaneUrl, tenantId, agentId, kid, opsToken }) { +export async function revokeKey({ controlPlaneUrl, tenantId, agentId, kid, opsToken, _fetch }) { + const fetchFn = _fetch || fetch; const url = `${controlPlaneUrl}/admin/v1/tenants/${encodeURIComponent(tenantId)}/agents/${encodeURIComponent(agentId)}/keys/${encodeURIComponent(kid)}`; const headers = {}; if (opsToken) headers['Authorization'] = `Bearer ${opsToken}`; - const res = await fetch(url, { method: 'DELETE', headers }); + const res = await fetchFn(url, { method: 'DELETE', headers }); if (!res.ok) throw await parseErrorResponse(res); return res.json(); @@ -145,13 +147,14 @@ export async function revokeKey({ controlPlaneUrl, tenantId, agentId, kid, opsTo * @returns {Promise>} list of key records * @throws {AdminPlaneError} */ -export async function listKeys({ controlPlaneUrl, tenantId, agentId, opsToken }) { +export async function listKeys({ controlPlaneUrl, tenantId, agentId, opsToken, _fetch }) { + const fetchFn = _fetch || fetch; const url = `${controlPlaneUrl}/admin/v1/tenants/${encodeURIComponent(tenantId)}/agents/${encodeURIComponent(agentId)}/keys`; const headers = {}; if (opsToken) headers['Authorization'] = `Bearer ${opsToken}`; - const res = await fetch(url, { headers }); + const res = await fetchFn(url, { headers }); if (!res.ok) throw await parseErrorResponse(res); return res.json(); diff --git a/src/lib/signing.js b/src/lib/signing.js index 82144cd..5e659fd 100644 --- a/src/lib/signing.js +++ b/src/lib/signing.js @@ -150,13 +150,18 @@ export function sign(privateKey, base) { * @param {string} opts.kid - key identifier * @param {number} [opts.created] - unix timestamp (defaults to now) * @param {string} [opts.nonce] - per-request nonce (defaults to random 16-byte hex) + * @param {string} [opts.pathPrefix] - prefix the reverse proxy strips (e.g. "/ajj/agent"); signature covers the internal path after stripping * @returns {object} headers object */ -export function signRequest({ privateKey, method, url, body, tenantId, agentId, kid, created, nonce }) { +export function signRequest({ privateKey, method, url, body, tenantId, agentId, kid, created, nonce, pathPrefix }) { const bodyBuf = Buffer.isBuffer(body) ? body : Buffer.from(body || ''); const parsedUrl = new URL(url, 'https://placeholder'); - const path = encodePath(parsedUrl.pathname); + let pathname = parsedUrl.pathname; + if (pathPrefix && pathname.startsWith(pathPrefix)) { + pathname = pathname.slice(pathPrefix.length) || '/'; + } + const path = encodePath(pathname); const digest = contentDigest(bodyBuf); const ts = created ?? Math.floor(Date.now() / 1000); diff --git a/src/lib/version.js b/src/lib/version.js new file mode 100644 index 0000000..3159654 --- /dev/null +++ b/src/lib/version.js @@ -0,0 +1,67 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8')); + +export const VERSION = pkg.version; + +const GITHUB_RELEASES_URL = 'https://api.github.com/repos/openmaxai/openmax-agentgate/releases/latest'; + +function semverCompare(a, b) { + const pa = a.split('.').map(Number); + const pb = b.split('.').map(Number); + for (let i = 0; i < 3; i++) { + if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0); + } + return 0; +} + +/** + * Check for available upgrades via GitHub Releases. + * + * @param {object} [opts] + * @param {typeof globalThis.fetch} [opts.fetch] - injectable fetch for testing + * @returns {Promise<{current: string, latest: string|null, upgrade_available: boolean, source: string, error?: string}>} + */ +export async function checkUpgrade(opts = {}) { + const fetchFn = opts.fetch || globalThis.fetch; + try { + const res = await fetchFn(GITHUB_RELEASES_URL, { + headers: { 'Accept': 'application/vnd.github+json', 'User-Agent': 'openmax-agentgate' }, + signal: AbortSignal.timeout(10000), + }); + + if (res.status === 403 || res.status === 429) { + return { current: VERSION, latest: null, upgrade_available: false, source: 'github-releases', error: 'GitHub API rate limit exceeded. Try again later or set GITHUB_TOKEN.' }; + } + if (res.status === 404) { + return { current: VERSION, latest: null, upgrade_available: false, source: 'github-releases', error: 'No releases found. This may be the first version.' }; + } + if (!res.ok) { + return { current: VERSION, latest: null, upgrade_available: false, source: 'github-releases', error: `GitHub API returned HTTP ${res.status}` }; + } + + const data = await res.json(); + const tagName = data.tag_name || ''; + const latest = tagName.replace(/^v/, ''); + + if (!latest || !/^\d+\.\d+\.\d+/.test(latest)) { + return { current: VERSION, latest: null, upgrade_available: false, source: 'github-releases', error: `Invalid tag format: ${tagName}` }; + } + + return { + current: VERSION, + latest, + upgrade_available: semverCompare(latest, VERSION) > 0, + source: 'github-releases', + }; + } catch (err) { + const message = err.name === 'TimeoutError' ? 'Request timed out' : + err.name === 'AbortError' ? 'Request aborted' : + err.code === 'ENOTFOUND' ? 'Network unreachable' : + err.message; + return { current: VERSION, latest: null, upgrade_available: false, source: 'github-releases', error: message }; + } +} diff --git a/test/client.test.js b/test/client.test.js new file mode 100644 index 0000000..8f5db3b --- /dev/null +++ b/test/client.test.js @@ -0,0 +1,142 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import { AgentSigner, createSigner } from '../src/lib/client.js'; +import { signRequest, signingBase, encodePath, contentDigest } from '../src/lib/signing.js'; +import { generateKeyPair, privateKeyFromSeed } from '../src/lib/keygen.js'; + +const seed = Buffer.from('9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60', 'hex'); +const privateKey = privateKeyFromSeed(seed); + +describe('pathPrefix — signed path ≠ requested path', () => { + const PREFIX = '/ajj/agent'; + const PUBLIC_URL = 'https://gateway.example/ajj/agent/api/v1/query?foo=bar'; + const INTERNAL_PATH = '/api/v1/query'; + const body = '{"action":"test"}'; + const opts = { + privateKey, + method: 'POST', + url: PUBLIC_URL, + body, + tenantId: 'tenant-x', + agentId: 'agent-y', + kid: 'k1', + created: 1700000000, + nonce: 'deadbeef', + }; + + it('without pathPrefix, signature covers the full public path', () => { + const headers = signRequest({ ...opts, pathPrefix: undefined }); + + const base = signingBase({ + method: 'POST', + path: encodePath('/ajj/agent/api/v1/query'), + contentDigest: contentDigest(Buffer.from(body)), + tenantId: 'tenant-x', + agentId: 'agent-y', + created: 1700000000, + nonce: 'deadbeef', + kid: 'k1', + }); + const expectedSig = crypto.sign(null, base, privateKey).toString('base64'); + assert.equal(headers['Signature'], `sig1=:${expectedSig}:`); + }); + + it('with pathPrefix, signature covers the stripped internal path', () => { + const headers = signRequest({ ...opts, pathPrefix: PREFIX }); + + const base = signingBase({ + method: 'POST', + path: encodePath(INTERNAL_PATH), + contentDigest: contentDigest(Buffer.from(body)), + tenantId: 'tenant-x', + agentId: 'agent-y', + created: 1700000000, + nonce: 'deadbeef', + kid: 'k1', + }); + const expectedSig = crypto.sign(null, base, privateKey).toString('base64'); + assert.equal(headers['Signature'], `sig1=:${expectedSig}:`); + }); + + it('signed path (with prefix) ≠ signed path (without prefix)', () => { + const withPrefix = signRequest({ ...opts, pathPrefix: PREFIX }); + const withoutPrefix = signRequest({ ...opts, pathPrefix: undefined }); + + assert.notEqual(withPrefix['Signature'], withoutPrefix['Signature'], + 'pathPrefix must change the signature (proves different signing base)'); + }); + + it('prefix stripping falls back to "/" when path equals prefix exactly', () => { + const headers = signRequest({ + ...opts, + url: 'https://gateway.example/ajj/agent', + pathPrefix: PREFIX, + }); + + const base = signingBase({ + method: 'POST', + path: '/', + contentDigest: contentDigest(Buffer.from(body)), + tenantId: 'tenant-x', + agentId: 'agent-y', + created: 1700000000, + nonce: 'deadbeef', + kid: 'k1', + }); + const expectedSig = crypto.sign(null, base, privateKey).toString('base64'); + assert.equal(headers['Signature'], `sig1=:${expectedSig}:`); + }); + + it('prefix that does not match is a no-op', () => { + const withWrongPrefix = signRequest({ ...opts, pathPrefix: '/other/prefix' }); + const withoutPrefix = signRequest({ ...opts, pathPrefix: undefined }); + + assert.equal(withWrongPrefix['Signature'], withoutPrefix['Signature'], + 'non-matching prefix must not alter the signature'); + }); +}); + +describe('AgentSigner with pathPrefix', () => { + it('passes pathPrefix through to signRequest', () => { + const signer = new AgentSigner({ + privateKey, + tenantId: 'tenant-x', + agentId: 'agent-y', + kid: 'k1', + pathPrefix: '/ajj/agent', + }); + + assert.equal(signer.pathPrefix, '/ajj/agent'); + + const headers = signer.sign('POST', 'https://gw.example/ajj/agent/api/v1/query', '{}'); + const directHeaders = signRequest({ + privateKey, + method: 'POST', + url: 'https://gw.example/ajj/agent/api/v1/query', + body: '{}', + tenantId: 'tenant-x', + agentId: 'agent-y', + kid: 'k1', + pathPrefix: '/ajj/agent', + created: undefined, + nonce: undefined, + }); + + // Can't compare signatures directly (different nonce/timestamp), but verify the + // signature is computed over the stripped path by checking Content-Digest matches + assert.equal(headers['Content-Digest'], directHeaders['Content-Digest']); + assert.equal(headers['X-Openmax-Tenant'], 'tenant-x'); + assert.equal(headers['X-Openmax-Agent'], 'agent-y'); + }); + + it('defaults pathPrefix to empty string when not provided', () => { + const signer = new AgentSigner({ + privateKey, + tenantId: 't', + agentId: 'a', + kid: 'k', + }); + assert.equal(signer.pathPrefix, ''); + }); +}); diff --git a/test/keygen.test.js b/test/keygen.test.js new file mode 100644 index 0000000..a231b49 --- /dev/null +++ b/test/keygen.test.js @@ -0,0 +1,154 @@ +import { describe, it, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { + generateKeyPair, + exportPublicKeyBase64, + publicKeyFingerprint, + savePrivateKey, + loadPrivateKey, + privateKeyFromSeed, + defaultKeyPath, +} from '../src/lib/keygen.js'; + +let tmpDir; +afterEach(() => { + if (tmpDir && fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + tmpDir = undefined; +}); + +function makeTmpDir() { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentgate-test-')); + return tmpDir; +} + +describe('generateKeyPair', () => { + it('returns an Ed25519 key pair', () => { + const { publicKey, privateKey } = generateKeyPair(); + assert.equal(publicKey.type, 'public'); + assert.equal(privateKey.type, 'private'); + assert.equal(publicKey.asymmetricKeyType, 'ed25519'); + assert.equal(privateKey.asymmetricKeyType, 'ed25519'); + }); +}); + +describe('exportPublicKeyBase64', () => { + it('returns base64 of the 32-byte raw key (SPKI prefix stripped)', () => { + const { publicKey } = generateKeyPair(); + const b64 = exportPublicKeyBase64(publicKey); + const raw = Buffer.from(b64, 'base64'); + assert.equal(raw.length, 32, 'raw public key must be 32 bytes'); + }); + + it('round-trips: decoded base64 is exactly 32 bytes', () => { + const { publicKey } = generateKeyPair(); + const b64 = exportPublicKeyBase64(publicKey); + assert.ok(/^[A-Za-z0-9+/]+=*$/.test(b64), 'must be valid base64'); + const decoded = Buffer.from(b64, 'base64'); + assert.equal(decoded.length, 32); + }); +}); + +describe('publicKeyFingerprint', () => { + it('returns sha256 hex of the raw key, 64 chars', () => { + const { publicKey } = generateKeyPair(); + const fp = publicKeyFingerprint(publicKey); + assert.equal(fp.length, 64, 'fingerprint must be 64 hex chars'); + assert.ok(/^[0-9a-f]{64}$/.test(fp), 'must be lowercase hex'); + }); + + it('matches manual sha256 of the raw key', () => { + const { publicKey } = generateKeyPair(); + const raw = publicKey.export({ type: 'spki', format: 'der' }).subarray(12); + const expected = crypto.createHash('sha256').update(raw).digest('hex'); + assert.equal(publicKeyFingerprint(publicKey), expected); + }); +}); + +describe('savePrivateKey + loadPrivateKey round-trip', () => { + it('saves and loads a key that produces the same signature', () => { + const dir = makeTmpDir(); + const keyPath = path.join(dir, 'test.pem'); + const { publicKey, privateKey } = generateKeyPair(); + + savePrivateKey(privateKey, keyPath); + const loaded = loadPrivateKey(keyPath); + + const data = Buffer.from('test data'); + const sig1 = crypto.sign(null, data, privateKey); + const sig2 = crypto.sign(null, data, loaded); + assert.deepStrictEqual(sig1, sig2, 'signatures must match after round-trip'); + }); + + it('saves with 0600 permissions', () => { + if (process.platform === 'win32') return; + const dir = makeTmpDir(); + const keyPath = path.join(dir, 'perm.pem'); + const { privateKey } = generateKeyPair(); + + savePrivateKey(privateKey, keyPath); + const stat = fs.statSync(keyPath); + const perm = stat.mode & 0o777; + assert.equal(perm, 0o600, `permissions must be 0600, got ${perm.toString(8)}`); + }); +}); + +describe('loadPrivateKey permission check', () => { + it('rejects permissions broader than 0600', () => { + if (process.platform === 'win32') return; + const dir = makeTmpDir(); + const keyPath = path.join(dir, 'broad.pem'); + const { privateKey } = generateKeyPair(); + + savePrivateKey(privateKey, keyPath); + fs.chmodSync(keyPath, 0o644); + + assert.throws(() => loadPrivateKey(keyPath), /permissions too broad/); + }); +}); + +describe('privateKeyFromSeed', () => { + it('matches the golden vector seed from testdata', () => { + const seed = Buffer.from('4242424242424242424242424242424242424242424242424242424242424242', 'hex'); + const key = privateKeyFromSeed(seed); + assert.equal(key.asymmetricKeyType, 'ed25519'); + + const pubRaw = crypto.createPublicKey(key).export({ type: 'spki', format: 'der' }).subarray(12); + const expectedPub = Buffer.from('IVL40Zt5HSRFMkLhXy6rbLfP+ntqXtMAl5YOBpiB2xI=', 'base64'); + assert.deepStrictEqual(pubRaw, expectedPub, 'public key derived from golden vector seed must match'); + }); + + it('is deterministic — same seed always produces the same key', () => { + const seed = Buffer.alloc(32, 0x01); + const key1 = privateKeyFromSeed(seed); + const key2 = privateKeyFromSeed(seed); + const pub1 = crypto.createPublicKey(key1).export({ type: 'spki', format: 'der' }); + const pub2 = crypto.createPublicKey(key2).export({ type: 'spki', format: 'der' }); + assert.deepStrictEqual(pub1, pub2); + }); + + it('rejects seeds that are not 32 bytes', () => { + assert.throws(() => privateKeyFromSeed(Buffer.alloc(16)), /32 bytes/); + assert.throws(() => privateKeyFromSeed(Buffer.alloc(64)), /32 bytes/); + }); +}); + +describe('defaultKeyPath', () => { + it('sanitizes path separators in kid', () => { + const p = defaultKeyPath('my/key../../etc'); + const basename = path.basename(p); + assert.ok(!basename.includes('/'), 'must not contain /'); + assert.ok(basename.endsWith('.pem'), 'must end with .pem'); + }); + + it('preserves safe characters', () => { + const p = defaultKeyPath('agent-2026a.prod'); + const basename = path.basename(p); + assert.equal(basename, 'agent-2026a.prod.pem'); + }); +}); diff --git a/test/register.test.js b/test/register.test.js new file mode 100644 index 0000000..311f53f --- /dev/null +++ b/test/register.test.js @@ -0,0 +1,164 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { generateKeyPair, exportPublicKeyBase64, publicKeyFingerprint } from '../src/lib/keygen.js'; +import { registerKey, revokeKey, listKeys, AdminPlaneError, REGISTER_ALG } from '../src/lib/register.js'; + +function mockFetch(status, body, headers = {}) { + return async (url, opts) => ({ + ok: status >= 200 && status < 300, + status, + headers: new Headers(headers), + json: async () => body, + text: async () => JSON.stringify(body), + }); +} + +function capturingFetch(status, body) { + const captured = {}; + const fn = async (url, opts) => { + captured.url = url; + captured.method = opts?.method || 'GET'; + captured.headers = opts?.headers; + captured.body = opts?.body ? JSON.parse(opts.body) : undefined; + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + text: async () => JSON.stringify(body), + }; + }; + return { fetch: fn, captured }; +} + +const { publicKey } = generateKeyPair(); + +describe('registerKey', () => { + it('sends POST to the correct URL', async () => { + const { fetch, captured } = capturingFetch(201, { kid: 'k1', fingerprint: 'abc', registered_at: '2026-01-01T00:00:00Z' }); + + await registerKey({ + controlPlaneUrl: 'https://cp.example', + tenantId: 'tenant-a', + agentId: 'agent-1', + kid: 'k1', + publicKey, + opsToken: 'tok', + _fetch: fetch, + }); + + assert.equal(captured.url, 'https://cp.example/admin/v1/tenants/tenant-a/agents/agent-1/keys'); + assert.equal(captured.method, 'POST'); + }); + + it('sends exactly kid, alg, pubkey_b64 in the body', async () => { + const { fetch, captured } = capturingFetch(201, { kid: 'k1' }); + + await registerKey({ + controlPlaneUrl: 'https://cp.example', + tenantId: 't', + agentId: 'a', + kid: 'k1', + publicKey, + opsToken: 'tok', + _fetch: fetch, + }); + + const bodyKeys = Object.keys(captured.body).sort(); + assert.deepStrictEqual(bodyKeys, ['alg', 'kid', 'pubkey_b64']); + assert.equal(captured.body.kid, 'k1'); + assert.equal(captured.body.alg, 'Ed25519'); + assert.equal(captured.body.pubkey_b64, exportPublicKeyBase64(publicKey)); + }); + + it('alg is exactly "Ed25519" (capital E)', async () => { + const { fetch, captured } = capturingFetch(201, {}); + await registerKey({ controlPlaneUrl: 'https://cp.example', tenantId: 't', agentId: 'a', kid: 'k', publicKey, opsToken: 'tok', _fetch: fetch }); + assert.equal(captured.body.alg, 'Ed25519'); + assert.notEqual(captured.body.alg, 'ed25519'); + }); + + it('sets Authorization header with ops token', async () => { + const { fetch, captured } = capturingFetch(201, {}); + await registerKey({ controlPlaneUrl: 'https://cp.example', tenantId: 't', agentId: 'a', kid: 'k', publicKey, opsToken: 'my-token', _fetch: fetch }); + assert.equal(captured.headers['Authorization'], 'Bearer my-token'); + }); + + it('URL-encodes tenant and agent with special chars', async () => { + const { fetch, captured } = capturingFetch(201, {}); + await registerKey({ controlPlaneUrl: 'https://cp.example', tenantId: 'a/b', agentId: 'c d', kid: 'k', publicKey, opsToken: 'tok', _fetch: fetch }); + assert.ok(captured.url.includes('a%2Fb'), 'tenant must be URL-encoded'); + assert.ok(captured.url.includes('c%20d'), 'agent must be URL-encoded'); + }); +}); + +describe('registerKey error mapping', () => { + const cases = [ + { status: 400, code: 'INVALID_PARAM', retryable: false }, + { status: 401, code: 'AUTH_FAILED', retryable: false }, + { status: 404, code: 'QUERY_NOT_FOUND', retryable: false }, + { status: 500, code: 'INTERNAL', retryable: true }, + ]; + + for (const c of cases) { + it(`${c.status} → code=${c.code}, retryable=${c.retryable}`, async () => { + const fetch = mockFetch(c.status, { error_code: c.code, message: 'test', request_id: 'req-1' }); + try { + await registerKey({ controlPlaneUrl: 'https://cp.example', tenantId: 't', agentId: 'a', kid: 'k', publicKey, opsToken: 'tok', _fetch: fetch }); + assert.fail('should have thrown'); + } catch (err) { + assert.ok(err instanceof AdminPlaneError); + assert.equal(err.status, c.status); + assert.equal(err.code, c.code); + assert.equal(err.retryable, c.retryable); + assert.equal(err.requestId, 'req-1'); + } + }); + } + + it('only 500 is retryable', () => { + for (const status of [400, 401, 403, 404, 409, 422]) { + const err = new AdminPlaneError(status, 'TEST', 'msg', ''); + assert.equal(err.retryable, false, `${status} must not be retryable`); + } + const err500 = new AdminPlaneError(500, 'INTERNAL', 'msg', ''); + assert.equal(err500.retryable, true, '500 must be retryable'); + }); +}); + +describe('revokeKey', () => { + it('sends DELETE to the correct URL with kid', async () => { + const { fetch, captured } = capturingFetch(200, { kid: 'k1', revoked: true }); + + await revokeKey({ + controlPlaneUrl: 'https://cp.example', + tenantId: 'tenant-a', + agentId: 'agent-1', + kid: 'k1', + opsToken: 'tok', + _fetch: fetch, + }); + + assert.equal(captured.url, 'https://cp.example/admin/v1/tenants/tenant-a/agents/agent-1/keys/k1'); + assert.equal(captured.method, 'DELETE'); + }); +}); + +describe('listKeys', () => { + it('sends GET and returns a bare array', async () => { + const keys = [{ kid: 'k1', fingerprint: 'abc' }, { kid: 'k2', fingerprint: 'def' }]; + const { fetch, captured } = capturingFetch(200, keys); + + const result = await listKeys({ + controlPlaneUrl: 'https://cp.example', + tenantId: 'tenant-a', + agentId: 'agent-1', + opsToken: 'tok', + _fetch: fetch, + }); + + assert.equal(captured.url, 'https://cp.example/admin/v1/tenants/tenant-a/agents/agent-1/keys'); + assert.equal(captured.method, 'GET'); + assert.ok(Array.isArray(result)); + assert.equal(result.length, 2); + }); +});