Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
49 changes: 36 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id> [--out <path>]
agentgate register --kid <id> --tenant <t> --agent <a> --cp-url <url>
agentgate keys list --tenant <t> --agent <a> --cp-url <url>
agentgate keys revoke --kid <id> --tenant <t> --agent <a> --cp-url <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

Expand All @@ -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
```
Expand All @@ -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.
50 changes: 38 additions & 12 deletions SKILL.md
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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 <id> [--out <path>] # Generate Ed25519 key pair
agentgate register --kid <id> --tenant <t> --agent <a> --cp-url <url>
agentgate keys list --tenant <t> --agent <a> --cp-url <url>
agentgate keys revoke --kid <id> --tenant <t> --agent <a> --cp-url <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.
8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading
Loading