@kya-brasil/agent-tools 0.6.0 → 0.7.0

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
@@ -103,7 +103,7 @@ if (!callee?.ok) throw new Error("unsigned or invalid callee"); // callee.
103
103
  ## Company: give your own agent a credential
104
104
 
105
105
  ```ts
106
- import { createKyaClient, signChallengeWithEvmKey, signWithECnpj } from "@kya-brasil/agent-tools";
106
+ import { createKyaClient, signChallengeWithEvmKey, signingLink, signWithECnpjPfx } from "@kya-brasil/agent-tools";
107
107
  import { readFileSync } from "node:fs";
108
108
 
109
109
  const kya = createKyaClient({ privateKey: payerPk });
@@ -120,19 +120,29 @@ const reg = await kya.register({ // $0
120
120
  });
121
121
 
122
122
  const agent_signature = await signChallengeWithEvmKey(agentPk, reg.challenge.text);
123
- const ecnpj_signature = await signWithECnpj({
124
- certificatePem: readFileSync("ecnpj.crt.pem", "utf8"),
125
- privateKeyPem: readFileSync("ecnpj.key.pem", "utf8"),
126
- chainPem: [readFileSync("ecnpj.chain.pem", "utf8")]
127
- }, reg.challenge.text);
128
123
 
129
- const { credential } = await kya.prove(reg.credential_id, { challenge_id: reg.challenge.id, agent_signature, ecnpj_signature }); // free
124
+ // Usual case: a person holds the e-CNPJ (owner, accountant). Send them the link; they sign in the browser
125
+ // (.pfx, or a .p7s from any ICP-Brasil signer for A3 tokens and cloud certificates) and the credential is issued.
126
+ const link = signingLink({ credentialId: reg.credential_id, challenge: reg.challenge, agentSignature: agent_signature });
127
+ // (reg.sign_url is the same link without the agent signature)
128
+
129
+ // Or in code, when you hold the A1 file yourself:
130
+ const ecnpj_signature = signWithECnpjPfx(readFileSync("empresa.pfx"), process.env.PFX_PASSWORD!, reg.challenge.text);
131
+ const { credential, warnings } = await kya.prove(reg.credential_id, { challenge_id: reg.challenge.id, agent_signature, ecnpj_signature }); // free
130
132
  ```
131
133
 
132
- From an A1 `.pfx`: `openssl pkcs12 -in cert.pfx -clcerts -nokeys > ecnpj.crt.pem`,
133
- `openssl pkcs12 -in cert.pfx -nocerts -nodes | openssl pkcs8 -topk8 -nocrypt > ecnpj.key.pem`,
134
- `openssl pkcs12 -in cert.pfx -cacerts -nokeys > ecnpj.chain.pem`. A3 certificates (token, card) sign through the vendor's software;
135
- pass the resulting detached CMS as `ecnpj_signature`.
134
+ `signWithECnpjPfx` reads the `.pfx`/`.p12` directly, including the legacy 3DES/RC2 encryption many Brazilian A1
135
+ files use; `signWithECnpj` still takes PEM. On a terminal, the `kya-sign` command does the same without code:
136
+
137
+ ```sh
138
+ npx -p @kya-brasil/agent-tools kya-sign --pfx empresa.pfx --link "<sign_url>&agent_signature=…" # signs and issues
139
+ npx -p @kya-brasil/agent-tools kya-sign --pfx empresa.pfx --text-file desafio.txt # prints the signature
140
+ ```
141
+
142
+ The password comes from `KYA_PFX_PASSWORD` or is asked for without echo. Everything runs locally.
143
+
144
+ An agent should not hold the company's e-CNPJ to sign for itself: that signature is the company's consent.
145
+ Let the agent prepare and send the link, and a person sign.
136
146
 
137
147
  Renew every 90 days (`kya.renew` then `kya.renewProve`), revoke at once (`kya.revoke` with `revocationText(id)` signed by the e-CNPJ).
138
148
 
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * kya-sign: sign a KYA challenge (or a company request text) with the e-CNPJ in a .pfx / .p12 file.
4
+ * npx -p @kya-brasil/agent-tools kya-sign --pfx empresa.pfx --text-file desafio.txt
5
+ * npx -p @kya-brasil/agent-tools kya-sign --pfx empresa.pfx --link "https://…/sign?id=…&challenge_id=…&text=…&agent_signature=…"
6
+ * The password comes from KYA_PFX_PASSWORD or is asked for (not echoed). Prints the base64 signature, or with
7
+ * --link and an agent_signature in it, sends the proof and prints the issued credential.
8
+ * Everything happens on this machine; the certificate is never sent anywhere.
9
+ */
10
+ import { readFileSync } from "node:fs";
11
+ import { openPfx } from "./pfx.js";
12
+ const args = process.argv.slice(2);
13
+ const opt = (name) => {
14
+ const i = args.indexOf(`--${name}`);
15
+ return i >= 0 ? args[i + 1] : undefined;
16
+ };
17
+ const die = (msg) => {
18
+ process.stderr.write(`kya-sign: ${msg}\n`);
19
+ process.exit(1);
20
+ };
21
+ async function askPassword() {
22
+ if (process.env.KYA_PFX_PASSWORD !== undefined)
23
+ return process.env.KYA_PFX_PASSWORD;
24
+ if (!process.stdin.isTTY)
25
+ die("set KYA_PFX_PASSWORD (no terminal to ask for the password)");
26
+ process.stderr.write("Senha do certificado / certificate password: ");
27
+ process.stdin.setRawMode(true);
28
+ process.stdin.resume();
29
+ let pw = "";
30
+ return new Promise((resolve) => {
31
+ process.stdin.on("data", (b) => {
32
+ for (const ch of b.toString("utf8")) {
33
+ if (ch === "\r" || ch === "\n") {
34
+ process.stdin.setRawMode(false);
35
+ process.stdin.pause();
36
+ process.stderr.write("\n");
37
+ return resolve(pw);
38
+ }
39
+ if (ch === "\u0003")
40
+ process.exit(130);
41
+ pw = ch === "\u007f" ? pw.slice(0, -1) : pw + ch;
42
+ }
43
+ });
44
+ });
45
+ }
46
+ const fromB64u = (s) => new TextDecoder().decode(Uint8Array.from(atob(s.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0)));
47
+ async function main() {
48
+ if (args.includes("--help") || args.includes("-h") || !opt("pfx")) {
49
+ process.stdout.write("usage: kya-sign --pfx <file.pfx> (--text-file <file> | --text <text> | --link <signing link>)\n");
50
+ process.exit(args.includes("--help") || args.includes("-h") ? 0 : 2);
51
+ }
52
+ const link = opt("link") ? new URL(opt("link")) : null;
53
+ const text = link ? fromB64u(link.searchParams.get("text") ?? die("the link has no text")) : opt("text-file") ? readFileSync(opt("text-file"), "utf8") : (opt("text") ?? die("give --text-file, --text or --link"));
54
+ const signer = openPfx(new Uint8Array(readFileSync(opt("pfx"))), await askPassword());
55
+ const cnpj = /^cnpj: (\d{14})$/m.exec(text)?.[1];
56
+ process.stderr.write(`certificate: ${signer.subject}\n`);
57
+ if (cnpj && signer.cnpj && cnpj !== signer.cnpj)
58
+ die(`this certificate is for CNPJ ${signer.cnpj}, the text is for ${cnpj}`);
59
+ process.stderr.write(`signing:\n${text.split("\n").map((l) => ` ${l}`).join("\n")}\n`);
60
+ const signature = signer.sign(text);
61
+ const id = link?.searchParams.get("id");
62
+ const challengeId = link?.searchParams.get("challenge_id");
63
+ const agentSignature = link?.searchParams.get("agent_signature");
64
+ if (!link || !id || !challengeId || !agentSignature) {
65
+ process.stdout.write(`${signature}\n`);
66
+ return;
67
+ }
68
+ const res = await fetch(new URL(`/v1/agents/${encodeURIComponent(id)}/prove`, link.origin), {
69
+ method: "POST",
70
+ headers: { "content-type": "application/json" },
71
+ body: JSON.stringify({ challenge_id: challengeId, agent_signature: agentSignature, ecnpj_signature: signature })
72
+ });
73
+ const body = await res.text();
74
+ process.stdout.write(`${body}\n`);
75
+ if (!res.ok)
76
+ process.exit(1);
77
+ }
78
+ main().catch((e) => die(e.message));
package/dist/index.d.ts CHANGED
@@ -361,3 +361,19 @@ export declare function verifyCredentialOffline(credential: Credential, opts?: {
361
361
  now?: Date;
362
362
  }): Promise<OfflineCheck>;
363
363
  export * from "./auth.js";
364
+ export * from "./pfx.js";
365
+ /**
366
+ * Link to the issuer's signing page (<issuer>/sign) for the person who holds the company's e-CNPJ, usually the
367
+ * owner or the accountant. The agent registers, signs its half and sends this link; the person opens it, sees
368
+ * which agent, company and mandate they are authorizing, signs with the .pfx in the browser (the file never
369
+ * leaves the computer) or uploads a .p7s from any ICP-Brasil signer, and the credential is issued.
370
+ */
371
+ export declare function signingLink(p: {
372
+ credentialId: string;
373
+ challenge: {
374
+ id: string;
375
+ text: string;
376
+ };
377
+ agentSignature?: string;
378
+ baseUrl?: string;
379
+ }): string;
package/dist/index.js CHANGED
@@ -250,3 +250,19 @@ export async function verifyCredentialOffline(credential, opts = {}) {
250
250
  return { signature, within_validity, live_status: "unknown", usable_offline: signature.valid && within_validity && credential.status === "active" };
251
251
  }
252
252
  export * from "./auth.js";
253
+ export * from "./pfx.js";
254
+ /**
255
+ * Link to the issuer's signing page (<issuer>/sign) for the person who holds the company's e-CNPJ, usually the
256
+ * owner or the accountant. The agent registers, signs its half and sends this link; the person opens it, sees
257
+ * which agent, company and mandate they are authorizing, signs with the .pfx in the browser (the file never
258
+ * leaves the computer) or uploads a .p7s from any ICP-Brasil signer, and the credential is issued.
259
+ */
260
+ export function signingLink(p) {
261
+ const u = new URL("/sign", p.baseUrl ?? DEFAULT_BASE_URL);
262
+ u.searchParams.set("id", p.credentialId);
263
+ u.searchParams.set("challenge_id", p.challenge.id);
264
+ u.searchParams.set("text", toBase64Url(new TextEncoder().encode(p.challenge.text)));
265
+ if (p.agentSignature)
266
+ u.searchParams.set("agent_signature", p.agentSignature);
267
+ return u.toString();
268
+ }
package/dist/pfx.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ export interface PfxSigner {
2
+ /** Subject of the e-CNPJ certificate, e.g. "CN=ACME LTDA:11222333000181, ...". */
3
+ subject: string;
4
+ /** CNPJ read from the certificate's common name ("NAME:CNPJ"), when present. */
5
+ cnpj: string | null;
6
+ notAfter: Date;
7
+ /** Detached CMS/PKCS#7 SignedData (base64 DER) over `text`, with the certificate chain found in the file. */
8
+ sign(text: string): string;
9
+ }
10
+ /** Opens a PKCS#12 file (bytes) with its password; throws a readable error on a wrong password or an empty file. */
11
+ export declare function openPfx(pfx: Uint8Array, password: string): PfxSigner;
12
+ /** One call: sign `text` with the e-CNPJ in a .pfx/.p12 file. Returns the base64 DER the issuer expects as ecnpj_signature. */
13
+ export declare function signWithECnpjPfx(pfx: Uint8Array, password: string, text: string): string;
package/dist/pfx.js ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * e-CNPJ signing straight from the .pfx / .p12 file companies actually have (A1 certificates). node-forge reads
3
+ * the legacy PKCS#12 encryptions (3DES, RC2) that Web Crypto cannot, which many Brazilian A1 files still use.
4
+ * The page at <issuer>/sign runs the same steps in the browser; keep the two in sync.
5
+ */
6
+ import forge from "node-forge";
7
+ const OID = {
8
+ shroudedKeyBag: forge.pki.oids.pkcs8ShroudedKeyBag,
9
+ keyBag: forge.pki.oids.keyBag,
10
+ certBag: forge.pki.oids.certBag,
11
+ sha256: forge.pki.oids.sha256,
12
+ contentType: forge.pki.oids.contentType,
13
+ data: forge.pki.oids.data,
14
+ messageDigest: forge.pki.oids.messageDigest,
15
+ signingTime: forge.pki.oids.signingTime
16
+ };
17
+ const cnOf = (c) => String(c.subject.getField("CN")?.value ?? "");
18
+ const cnpjOfCn = (cn) => /:(\d{14})\s*$/.exec(cn)?.[1] ?? null;
19
+ /** Opens a PKCS#12 file (bytes) with its password; throws a readable error on a wrong password or an empty file. */
20
+ export function openPfx(pfx, password) {
21
+ let p12;
22
+ try {
23
+ p12 = forge.pkcs12.pkcs12FromAsn1(forge.asn1.fromDer(forge.util.binary.raw.encode(pfx)), false, password);
24
+ }
25
+ catch (e) {
26
+ throw new Error(`could not open the certificate file (wrong password or not a .pfx/.p12): ${e.message}`);
27
+ }
28
+ const keyBag = [OID.shroudedKeyBag, OID.keyBag].flatMap((t) => p12.getBags({ bagType: t })[t] ?? []).find((b) => b.key);
29
+ if (!keyBag?.key)
30
+ throw new Error("the file has no private key");
31
+ const key = keyBag.key;
32
+ const certs = (p12.getBags({ bagType: OID.certBag })[OID.certBag] ?? []).map((b) => b.cert).filter((c) => !!c);
33
+ // The signer is the certificate whose public key matches the private key; the others are its chain.
34
+ const leaf = certs.find((c) => c.publicKey.n?.equals(key.n));
35
+ if (!leaf)
36
+ throw new Error("no certificate in the file matches its private key");
37
+ return {
38
+ subject: leaf.subject.attributes.map((a) => `${a.shortName ?? a.name}=${a.value}`).join(", "),
39
+ cnpj: cnpjOfCn(cnOf(leaf)),
40
+ notAfter: leaf.validity.notAfter,
41
+ sign(text) {
42
+ const p7 = forge.pkcs7.createSignedData();
43
+ p7.content = forge.util.createBuffer(forge.util.encodeUtf8(text));
44
+ p7.addCertificate(leaf);
45
+ for (const c of certs)
46
+ if (c !== leaf)
47
+ p7.addCertificate(c);
48
+ p7.addSigner({
49
+ key,
50
+ certificate: leaf,
51
+ digestAlgorithm: OID.sha256,
52
+ authenticatedAttributes: [
53
+ { type: OID.contentType, value: OID.data },
54
+ { type: OID.messageDigest },
55
+ { type: OID.signingTime, value: new Date() }
56
+ ]
57
+ });
58
+ p7.sign({ detached: true });
59
+ return forge.util.encode64(forge.asn1.toDer(p7.toAsn1()).getBytes());
60
+ }
61
+ };
62
+ }
63
+ /** One call: sign `text` with the e-CNPJ in a .pfx/.p12 file. Returns the base64 DER the issuer expects as ecnpj_signature. */
64
+ export function signWithECnpjPfx(pfx, password, text) {
65
+ return openPfx(pfx, password).sign(text);
66
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kya-brasil/agent-tools",
3
- "version": "0.6.0",
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 against the Receita's open data, no third-party service), 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.7.0",
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 against the Receita's open data, no third-party service), check an action against its mandate, or give your own agent a credential (sign with the company's e-CNPJ .pfx, in code or with the kya-sign command). 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",
@@ -54,6 +54,7 @@
54
54
  "@x402/core": "^2.28.0",
55
55
  "@x402/evm": "^2.28.0",
56
56
  "@x402/fetch": "^2.28.0",
57
+ "node-forge": "1.4.0",
57
58
  "pkijs": "^3.4.1",
58
59
  "viem": "^2.57.5"
59
60
  },
@@ -63,8 +64,12 @@
63
64
  "@peculiar/asn1-x509": "^2.3.15",
64
65
  "@peculiar/x509": "^2.1.0",
65
66
  "@types/node": "^22.20.5",
67
+ "@types/node-forge": "^1.3.14",
66
68
  "reflect-metadata": "^0.2.2",
67
69
  "typescript": "^5.9.3",
68
70
  "vitest": "^4.1.11"
71
+ },
72
+ "bin": {
73
+ "kya-sign": "./dist/cli.js"
69
74
  }
70
75
  }