kestrel.markets
Version:
A typed, token-efficient language + runtime for agentic trading: agents author bounded plans, the runtime fires them at the tick. CLI + typed library + MCP server.
104 lines (93 loc) • 4.45 kB
text/typescript
/**
* # cli/keypair — the registered-agent Ed25519 keypair + request-JWT minter
* (kestrel-markets-0t0 SLICE 2, PLAT-ADR-0021).
*
* The client half of the AAP keypair rung. `kestrel register --keypair` generates an
* Ed25519 keypair, enrolls the PUBLIC half with the platform, and stores the PRIVATE
* half locally (0600, cli/credentials.ts). Thereafter the CLI authenticates by minting
* a SHORT-LIVED (≤5 min), single-use JWT it signs with the private key — a possession
* proof DISTINCT from replaying the durable bearer capability. The private key NEVER
* leaves the machine; only freshly-minted, minutes-long assertions cross the wire, so a
* captured request is worthless in minutes and useless on replay (the platform refuses a
* reused `jti`).
*
* Dependency-light on purpose: WebCrypto (`globalThis.crypto.subtle`, node ≥18 / bun) +
* node's `Buffer` for base64url — no third-party crypto, matching the platform's
* native-Ed25519 rule. The JWT wire format mirrors the platform verifier (agents/jwt.ts):
* header {alg:"EdDSA",typ:"JWT"}, claims { sub, aud, iat, exp, jti }.
*/
/** An Ed25519 public JWK (OKP) — the half enrolled with the platform. */
export interface Ed25519PublicJwk {
readonly kty: "OKP";
readonly crv: "Ed25519";
readonly x: string;
}
/** An Ed25519 private JWK (OKP) — held locally (0600), never sent. */
export interface Ed25519PrivateJwk extends Ed25519PublicJwk {
readonly d: string;
}
const ED25519 = { name: "Ed25519" } as const;
function subtle(): SubtleCrypto {
const c = (globalThis as { crypto?: Crypto }).crypto;
if (!c?.subtle) {
throw new Error("WebCrypto (crypto.subtle) unavailable — need node ≥18 or bun to use --keypair / JWT auth");
}
return c.subtle;
}
function b64url(bytes: Uint8Array): string {
return Buffer.from(bytes).toString("base64url");
}
/** Minimal structural view of an exported JWK (the OSS tsconfig lib omits the global type). */
type JwkExport = { x?: string; d?: string };
/** Generate a fresh Ed25519 keypair as JWKs (public + private halves). */
export async function generateAgentKeypair(): Promise<{ publicJwk: Ed25519PublicJwk; privateJwk: Ed25519PrivateJwk }> {
const kp = (await subtle().generateKey(ED25519, true, ["sign", "verify"])) as CryptoKeyPair;
const priv = (await subtle().exportKey("jwk", kp.privateKey)) as JwkExport;
const pub = (await subtle().exportKey("jwk", kp.publicKey)) as JwkExport;
return {
publicJwk: { kty: "OKP", crv: "Ed25519", x: pub.x! },
privateJwk: { kty: "OKP", crv: "Ed25519", x: priv.x!, d: priv.d! },
};
}
/** The claims a request JWT carries (times in SECONDS per RFC 7519). */
export interface RequestJwtClaims {
readonly sub: string;
readonly aud: string;
readonly iat: number;
readonly exp: number;
readonly jti: string;
}
export interface MintRequestJwtInput {
readonly privateJwk: Ed25519PrivateJwk;
/** The agent's subject (the platform looks the enrolled key up by it). */
readonly subject: string;
/** The audience the platform expects (stored from the enrollment response). */
readonly audience: string;
/** TTL in seconds (≤ 300; the platform refuses a longer span). Default 120. */
readonly ttlSec?: number;
/** Current time (epoch ms). Default `Date.now()`. */
readonly now?: number;
}
/**
* Mint a fresh, short-lived, single-use request JWT signed with the agent's private key.
* Each call yields a UNIQUE `jti` (via `crypto.randomUUID`) and a fresh `iat`/`exp`, so
* successive requests never collide with the platform's replay guard.
*/
export async function mintRequestJwt(input: MintRequestJwtInput): Promise<string> {
const nowMs = input.now ?? Date.now();
const iat = Math.floor(nowMs / 1000);
const ttl = Math.min(Math.max(input.ttlSec ?? 120, 1), 300);
const claims: RequestJwtClaims = {
sub: input.subject,
aud: input.audience,
iat,
exp: iat + ttl,
jti: `jwt_${(globalThis.crypto as Crypto).randomUUID().replace(/-/g, "")}`,
};
const key = await subtle().importKey("jwk", { ...input.privateJwk }, ED25519, false, ["sign"]);
const headerB64 = b64url(Buffer.from(JSON.stringify({ alg: "EdDSA", typ: "JWT" })));
const payloadB64 = b64url(Buffer.from(JSON.stringify(claims)));
const signingInput = `${headerB64}.${payloadB64}`;
const sig = await subtle().sign(ED25519, key, Buffer.from(signingInput));
return `${signingInput}.${b64url(new Uint8Array(sig))}`;
}