@kya-brasil/agent-tools 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # @kya-brasil/agent-tools
2
2
 
3
3
  **Know-your-agent for Brazil, as code.** Verify that an AI agent really acts for a Brazilian company
4
- (credential signed by the company's e-CNPJ, company cleared by [KYB Brasil](https://kyb-br-agents.marcelfons.workers.dev)),
4
+ (credential signed by the company's e-CNPJ, checked against its AC's revocation list, company active at the Receita Federal),
5
5
  check an action against the agent's mandate, or give your own agent a credential.
6
6
 
7
7
  Paid per call with [x402](https://x402.org) (USDC on Base, no signup). Free verification works without a wallet
@@ -11,6 +11,9 @@ Paid per call with [x402](https://x402.org) (USDC on Base, no signup). Free veri
11
11
  npm i @kya-brasil/agent-tools
12
12
  ```
13
13
 
14
+ The same build is mirrored to GitHub Packages as `@celfons/kya-agent-tools` (listed on the repository page;
15
+ installing from there needs a GitHub token). npmjs.com is the channel to install from.
16
+
14
17
  Node 18+ (global `fetch`, WebCrypto). ESM.
15
18
 
16
19
  ## Verifier: you receive an action from someone else's agent
@@ -69,6 +72,21 @@ await fetch(url, { method: "POST", headers: { ...headers, "content-type": "appli
69
72
 
70
73
  Keys: `{ kind: "evm" | "erc8004", privateKey }` or `{ kind: "web-bot-auth", privateKey: CryptoKey | pkcs8 }`.
71
74
 
75
+ ### Agent to agent (mutual)
76
+
77
+ The callee signs its response, bound to the request; the caller verifies who answered.
78
+
79
+ ```ts
80
+ // callee (inside a handler, after auth.authenticate succeeded)
81
+ const headers = await signResponse({ credentialId: myCredentialId, key: myKey, requestSignature: request.headers.get("kya-signature")!, body });
82
+ return new Response(body, { headers: { ...headers, "content-type": "application/json" } });
83
+
84
+ // caller
85
+ const me = createKyaAgent({ credentialId: myCredentialId, key: myKey });
86
+ const { response, callee } = await me.fetch("https://beta.com.br/orders", { method: "POST", body });
87
+ if (!callee?.ok) throw new Error("unsigned or invalid callee"); // callee.kya: principal, mandate, status
88
+ ```
89
+
72
90
  ## Company: give your own agent a credential
73
91
 
74
92
  ```ts
package/dist/auth.d.ts CHANGED
@@ -97,6 +97,13 @@ export interface KyaAuth {
97
97
  headers: Headers | Record<string, string | string[] | undefined>;
98
98
  body?: string | ArrayBuffer | Uint8Array | null;
99
99
  }): Promise<KyaAuthResult>;
100
+ /** Low level: verify a signature by a credential's agent key over a text you built (signed responses use it). */
101
+ verifySigned(p: {
102
+ credentialId: string;
103
+ text: string;
104
+ signature: string;
105
+ body?: KyaContext["body"];
106
+ }): Promise<KyaAuthResult>;
100
107
  /** Hono middleware: sets c.set("kya", ctx) or answers 401/403 JSON. */
101
108
  hono(): (c: HonoLikeContext, next: () => Promise<void>) => Promise<Response | void>;
102
109
  /** Express middleware: sets req.kya or answers 401/403 JSON. Verifies the body when req.body is a string or Buffer. */
@@ -128,3 +135,60 @@ export interface ExpressLikeResponse {
128
135
  json: (body: unknown) => unknown;
129
136
  }
130
137
  export declare function createKyaAuth(opts?: KyaAuthOptions): KyaAuth;
138
+ export declare const KYA_REPLY_HEADER = "kya-in-reply-to";
139
+ export declare function responseText(p: {
140
+ credentialId: string;
141
+ inReplyTo: string;
142
+ created: number;
143
+ expires: number;
144
+ contentSha256?: string | null;
145
+ }): string;
146
+ /** Callee side: headers that prove who answered, bound to the request signature it replies to. */
147
+ export declare function signResponse(p: {
148
+ credentialId: string;
149
+ key: SigningKey;
150
+ requestSignature: string;
151
+ body?: string | ArrayBuffer | Uint8Array | null;
152
+ ttlSeconds?: number;
153
+ now?: Date;
154
+ }): Promise<Record<string, string>>;
155
+ export type KyaResponseResult = {
156
+ ok: true;
157
+ kya: Omit<KyaContext, "check" | "allowed"> & {
158
+ check: KyaContext["check"];
159
+ allowed: KyaContext["allowed"];
160
+ };
161
+ } | {
162
+ ok: false;
163
+ error: string;
164
+ message: string;
165
+ };
166
+ /**
167
+ * Caller side: verify that a response was signed by an active KYA credential and answers *this* request.
168
+ * `requestSignature` is the KYA-Signature header the caller sent.
169
+ */
170
+ export declare function verifyResponse(auth: KyaAuth, response: Response, requestSignature: string, opts?: {
171
+ body?: string | ArrayBuffer | Uint8Array | null;
172
+ maxSkewSeconds?: number;
173
+ }): Promise<KyaResponseResult>;
174
+ /**
175
+ * Caller side, all in one: a fetch that signs the request with your credential and, when the other side
176
+ * answers with KYA headers, verifies them. `callee` is null when the response was not signed.
177
+ */
178
+ export declare function createKyaAgent(p: {
179
+ credentialId: string;
180
+ key: SigningKey;
181
+ auth?: KyaAuth;
182
+ authOptions?: KyaAuthOptions;
183
+ fetch?: typeof fetch;
184
+ ttlSeconds?: number;
185
+ }): {
186
+ auth: KyaAuth;
187
+ headers: (method: string, url: string, body?: string | ArrayBuffer | Uint8Array | null) => Promise<Record<string, string>>;
188
+ fetch(url: string, init?: RequestInit & {
189
+ body?: string | ArrayBuffer | Uint8Array | null;
190
+ }): Promise<{
191
+ response: Response;
192
+ callee: KyaResponseResult | null;
193
+ }>;
194
+ };
package/dist/auth.js CHANGED
@@ -193,9 +193,12 @@ export function createKyaAuth(opts = {}) {
193
193
  return status === 404 ? fail(401, "KYA_CREDENTIAL_UNKNOWN", "unknown credential_id") : fail(502, "KYA_ISSUER_UNAVAILABLE", e.message);
194
194
  }
195
195
  const text = requestText({ credentialId, method: p.method, url: p.url, created, expires, contentSha256: contentHeader });
196
+ return finish(credentialId, v, text, signature, bodyState);
197
+ }
198
+ async function finish(credentialId, v, text, signature, bodyState) {
196
199
  const sig = await verifySignature(v.agent, text, signature);
197
200
  if (!sig.ok)
198
- return fail(401, "KYA_BAD_SIGNATURE", "signature does not match the credential's agent key over the request text");
201
+ return fail(401, "KYA_BAD_SIGNATURE", "signature does not match the credential's agent key over the signed text");
199
202
  if (v.status !== "active")
200
203
  return fail(403, "KYA_CREDENTIAL_NOT_ACTIVE", `credential is ${v.status}${v.status_reason ? `: ${v.status_reason}` : ""}`);
201
204
  const mandate = v.credential?.mandate ?? {};
@@ -213,9 +216,22 @@ export function createKyaAuth(opts = {}) {
213
216
  };
214
217
  return { ok: true, kya: ctx };
215
218
  }
219
+ /** Low level: verify a signature by a credential's agent key over a text you built yourself (used for signed responses). */
220
+ async function verifySigned(p) {
221
+ let v;
222
+ try {
223
+ v = await verification(p.credentialId);
224
+ }
225
+ catch (e) {
226
+ const status = e.status;
227
+ return status === 404 ? fail(401, "KYA_CREDENTIAL_UNKNOWN", "unknown credential_id") : fail(502, "KYA_ISSUER_UNAVAILABLE", e.message);
228
+ }
229
+ return finish(p.credentialId, v, p.text, p.signature, p.body ?? "none");
230
+ }
216
231
  const auth = {
217
232
  client,
218
233
  authenticateParts,
234
+ verifySigned,
219
235
  async authenticate(request, o = {}) {
220
236
  let body = o.body;
221
237
  if (body === undefined && request.body && !["GET", "HEAD"].includes(request.method))
@@ -259,3 +275,97 @@ export function createKyaAuth(opts = {}) {
259
275
  };
260
276
  return auth;
261
277
  }
278
+ // --- Mutual authentication (agent to agent) --------------------------------------------------
279
+ //
280
+ // When both sides are agents, the caller proves itself with the request headers above and the callee
281
+ // proves itself by signing the response, bound to the request it answers:
282
+ //
283
+ // KYA Brasil: response
284
+ // credential: <callee credential_id>
285
+ // in-reply-to: <sha256 hex of the request's KYA-Signature>
286
+ // created: <unix seconds>
287
+ // expires: <unix seconds>
288
+ // content-sha256: <hex of the response body | ->
289
+ //
290
+ // Response headers: the same KYA-* names plus KYA-In-Reply-To. The caller verifies with the callee's
291
+ // credential (agent key, active status) exactly as a server verifies a request.
292
+ export const KYA_REPLY_HEADER = "kya-in-reply-to";
293
+ export function responseText(p) {
294
+ return ["KYA Brasil: response", `credential: ${p.credentialId}`, `in-reply-to: ${p.inReplyTo}`, `created: ${p.created}`, `expires: ${p.expires}`, `content-sha256: ${p.contentSha256 ?? "-"}`].join("\n");
295
+ }
296
+ async function signText(key, text) {
297
+ if (key.kind === "web-bot-auth") {
298
+ const k = typeof key.privateKey === "string" ? await crypto.subtle.importKey("pkcs8", unb64u(key.privateKey.replace(/-----[^-]+-----/g, "").replace(/\s+/g, "")), { name: "Ed25519" }, false, ["sign"]) : key.privateKey;
299
+ return b64u(new Uint8Array(await crypto.subtle.sign({ name: "Ed25519" }, k, enc.encode(text))));
300
+ }
301
+ return privateKeyToAccount(key.privateKey).signMessage({ message: text });
302
+ }
303
+ /** Callee side: headers that prove who answered, bound to the request signature it replies to. */
304
+ export async function signResponse(p) {
305
+ const created = Math.floor((p.now ?? new Date()).getTime() / 1000);
306
+ const expires = created + (p.ttlSeconds ?? 60);
307
+ const inReplyTo = await sha256Hex(p.requestSignature);
308
+ const contentSha256 = p.body !== undefined && p.body !== null && (typeof p.body !== "string" || p.body.length > 0) ? await sha256Hex(p.body) : null;
309
+ const signature = await signText(p.key, responseText({ credentialId: p.credentialId, inReplyTo, created, expires, contentSha256 }));
310
+ const headers = { [KYA_HEADERS.credential]: p.credentialId, [KYA_REPLY_HEADER]: inReplyTo, [KYA_HEADERS.created]: String(created), [KYA_HEADERS.expires]: String(expires), [KYA_HEADERS.signature]: signature };
311
+ if (contentSha256)
312
+ headers[KYA_HEADERS.content] = contentSha256;
313
+ return headers;
314
+ }
315
+ /**
316
+ * Caller side: verify that a response was signed by an active KYA credential and answers *this* request.
317
+ * `requestSignature` is the KYA-Signature header the caller sent.
318
+ */
319
+ export async function verifyResponse(auth, response, requestSignature, opts = {}) {
320
+ const h = response.headers;
321
+ const credentialId = h.get(KYA_HEADERS.credential);
322
+ const inReplyTo = h.get(KYA_REPLY_HEADER);
323
+ const created = Number(h.get(KYA_HEADERS.created));
324
+ const expires = Number(h.get(KYA_HEADERS.expires));
325
+ const signature = h.get(KYA_HEADERS.signature);
326
+ const contentHeader = h.get(KYA_HEADERS.content);
327
+ const fail = (error, message) => ({ ok: false, error, message });
328
+ if (!credentialId || !inReplyTo || !signature || !Number.isFinite(created) || !Number.isFinite(expires))
329
+ return fail("KYA_RESPONSE_UNSIGNED", "the response carries no KYA signature");
330
+ if (inReplyTo !== (await sha256Hex(requestSignature)))
331
+ return fail("KYA_RESPONSE_MISMATCH", "the response answers a different request");
332
+ const now = Math.floor(Date.now() / 1000);
333
+ if (created > now + (opts.maxSkewSeconds ?? 300) || expires < now)
334
+ return fail("KYA_RESPONSE_EXPIRED", "response signature outside its validity window");
335
+ let body = opts.body;
336
+ if (body === undefined)
337
+ body = await response.clone().arrayBuffer();
338
+ let bodyState = "none";
339
+ if (body && (typeof body !== "string" ? body.byteLength > 0 : body.length > 0)) {
340
+ if (!contentHeader)
341
+ return fail("KYA_RESPONSE_BODY_UNSIGNED", "the response has a body but no content hash");
342
+ if ((await sha256Hex(body)) !== contentHeader.toLowerCase())
343
+ return fail("KYA_RESPONSE_BODY_MISMATCH", "response body does not match the signed hash");
344
+ bodyState = "verified";
345
+ }
346
+ // Same credential lookup, key selection and status rule as for requests, over the response text.
347
+ const probe = await auth.verifySigned({ credentialId, text: responseText({ credentialId, inReplyTo, created, expires, contentSha256: contentHeader }), signature, body: bodyState });
348
+ if (!probe.ok)
349
+ return fail(probe.error.replace("KYA_", "KYA_RESPONSE_"), probe.message);
350
+ return { ok: true, kya: probe.kya };
351
+ }
352
+ /**
353
+ * Caller side, all in one: a fetch that signs the request with your credential and, when the other side
354
+ * answers with KYA headers, verifies them. `callee` is null when the response was not signed.
355
+ */
356
+ export function createKyaAgent(p) {
357
+ const auth = p.auth ?? createKyaAuth(p.authOptions);
358
+ const base = p.fetch ?? fetch;
359
+ return {
360
+ auth,
361
+ headers: (method, url, body) => signRequest({ credentialId: p.credentialId, key: p.key, method, url, body, ttlSeconds: p.ttlSeconds }),
362
+ async fetch(url, init = {}) {
363
+ const method = (init.method ?? "GET").toUpperCase();
364
+ const body = init.body ?? null;
365
+ const signed = await signRequest({ credentialId: p.credentialId, key: p.key, method, url, body, ttlSeconds: p.ttlSeconds });
366
+ const response = await base(url, { ...init, method, headers: { ...init.headers, ...signed }, body: body });
367
+ const callee = response.headers.get(KYA_HEADERS.signature) ? await verifyResponse(auth, response, signed[KYA_HEADERS.signature]) : null;
368
+ return { response, callee };
369
+ }
370
+ };
371
+ }
package/dist/index.d.ts CHANGED
@@ -56,7 +56,8 @@ export interface Credential {
56
56
  signed_at: string;
57
57
  };
58
58
  mandate: Mandate;
59
- kyb_snapshot: Record<string, unknown>;
59
+ /** Informative; null when the issuer has no KYB source. */
60
+ kyb_snapshot: Record<string, unknown> | null;
60
61
  status: CredentialStatus;
61
62
  issued_at: string;
62
63
  expires_at: string;
@@ -196,7 +197,7 @@ export interface KyaClient {
196
197
  cnpj: string;
197
198
  mandate: Mandate;
198
199
  }): Promise<Registration>;
199
- /** Free: send both signatures; the credential is issued if the e-CNPJ, the CNPJ and KYB Brasil all clear. */
200
+ /** Free: send both signatures; the credential is issued if the e-CNPJ is valid and not revoked, the CNPJ matches and the company is ATIVA. */
200
201
  prove(credentialId: string, proof: Proof): Promise<{
201
202
  credential: Credential;
202
203
  kyb_warnings: string[];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kya-brasil/agent-tools",
3
- "version": "0.3.0",
4
- "description": "KYA Brasil for AI agents: verify that an agent acts for a Brazilian company (e-CNPJ-signed, KYB-checked credential), check an action against its mandate, or give your own agent a credential. Sign in with KYA middleware (Hono, Express, Workers) and request signing. x402 payments (USDC on Base) handled for you. Adapters for Vercel AI SDK, LangChain, OpenAI and Anthropic.",
3
+ "version": "0.4.1",
4
+ "description": "KYA Brasil for AI agents: verify that an agent acts for a Brazilian company (e-CNPJ-signed credential, revocation and company registration checked daily), check an action against its mandate, or give your own agent a credential. Sign in with KYA middleware (Hono, Express, Workers) and request signing. x402 payments (USDC on Base) handled for you. Adapters for Vercel AI SDK, LangChain, OpenAI and Anthropic.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "./dist/index.js",