@kya-brasil/agent-tools 0.7.0 → 0.9.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
@@ -141,6 +141,31 @@ npx -p @kya-brasil/agent-tools kya-sign --pfx empresa.pfx --text-file desafio.tx
141
141
 
142
142
  The password comes from `KYA_PFX_PASSWORD` or is asked for without echo. Everything runs locally.
143
143
 
144
+ **A3 (token or smart card).** Through PKCS#11, the interface every ICP-Brasil token driver ships. Install the
145
+ optional `pkcs11js` and point to the vendor's library (e.g. SafeNet eToken `libeTPkcs11.so` / `eTPKCS11.dll`):
146
+
147
+ ```sh
148
+ npm install pkcs11js
149
+ npx -p @kya-brasil/agent-tools kya-sign --pkcs11 /usr/lib/libeTPkcs11.so --list # certificates on the token
150
+ npx -p @kya-brasil/agent-tools kya-sign --pkcs11 /usr/lib/libeTPkcs11.so --link "<sign_url>&agent_signature=…"
151
+ ```
152
+
153
+ ```ts
154
+ const token = await openToken({ library: "/usr/lib/libeTPkcs11.so", pin: process.env.TOKEN_PIN! });
155
+ try { ecnpj_signature = await token.sign(reg.challenge.text); } finally { token.close(); }
156
+ ```
157
+
158
+ The PIN comes from `KYA_TOKEN_PIN` or is asked for. The key never leaves the token. Tested against SoftHSM2 (a
159
+ software PKCS#11 token) in CI. The signing code is in `@kya-brasil/core` (`openPfx`, `ecnpjSigner`,
160
+ `buildDetachedCms`), re-exported here; any other token or cloud signer plugs into `ecnpjSigner`.
161
+
162
+ **Cloud certificate (PSC).** `kya-sign --cloud ./my-psc.mjs --link "<sign_url>"` with a module that implements
163
+ `CloudSignatureProvider` for your PSC ([template](https://github.com/celfons/kya-brasil/tree/main/examples/cloud-provider));
164
+ the browser opens for the approval and the terminal receives the redirect on `127.0.0.1`.
165
+
166
+ **From the agent's side**, `kya.signLink(credentialId, { challenge_id, agent_signature })` (free) returns the
167
+ `sign_url` to send, the same as `signingLink(...)` offline and the MCP tool `kya_sign_link`.
168
+
144
169
  An agent should not hold the company's e-CNPJ to sign for itself: that signature is the company's consent.
145
170
  Let the agent prepare and send the link, and a person sign.
146
171
 
package/dist/cli.js CHANGED
@@ -1,14 +1,24 @@
1
1
  #!/usr/bin/env node
2
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.
3
+ * kya-sign: sign a KYA challenge (or a company request text) with the company's e-CNPJ, A1 or A3.
4
+ * A1 file: kya-sign --pfx empresa.pfx --link "<sign_url>&agent_signature=…"
5
+ * A3 token: kya-sign --pkcs11 /usr/lib/libeTPkcs11.so --link "<sign_url>&agent_signature=…"
6
+ * kya-sign --pkcs11 <library> --list (certificates on the token)
7
+ * Cloud: kya-sign --cloud ./my-psc.mjs --link "<sign_url>&agent_signature=…"
8
+ * (a module exporting a CloudSignatureProvider: yours, for your PSC; the browser opens for the
9
+ * approval and comes back to a one-time local address)
10
+ * Text instead of a link: --text-file desafio.txt or --text "<text>" (prints the signature).
11
+ * The password (A1) comes from KYA_PFX_PASSWORD and the PIN (A3) from KYA_TOKEN_PIN, or are asked for without
12
+ * echo. With a link carrying agent_signature, the proof is sent and the issued credential printed. Everything
13
+ * happens on this machine; the certificate and its password or PIN are never sent anywhere.
9
14
  */
10
15
  import { readFileSync } from "node:fs";
11
- import { openPfx } from "./pfx.js";
16
+ import { createServer } from "node:http";
17
+ import { resolve } from "node:path";
18
+ import { pathToFileURL } from "node:url";
19
+ import { spawn } from "node:child_process";
20
+ import { cloudSigner, newState, pkcePair, openPfx, readSigningLink } from "@kya-brasil/core";
21
+ import { listTokenCertificates, openToken } from "./pkcs11.js";
12
22
  const args = process.argv.slice(2);
13
23
  const opt = (name) => {
14
24
  const i = args.indexOf(`--${name}`);
@@ -18,15 +28,15 @@ const die = (msg) => {
18
28
  process.stderr.write(`kya-sign: ${msg}\n`);
19
29
  process.exit(1);
20
30
  };
21
- async function askPassword() {
22
- if (process.env.KYA_PFX_PASSWORD !== undefined)
23
- return process.env.KYA_PFX_PASSWORD;
31
+ async function askSecret(envName, prompt) {
32
+ if (process.env[envName] !== undefined)
33
+ return process.env[envName];
24
34
  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: ");
35
+ die(`set ${envName} (no terminal to ask for it)`);
36
+ process.stderr.write(prompt);
27
37
  process.stdin.setRawMode(true);
28
38
  process.stdin.resume();
29
- let pw = "";
39
+ let secret = "";
30
40
  return new Promise((resolve) => {
31
41
  process.stdin.on("data", (b) => {
32
42
  for (const ch of b.toString("utf8")) {
@@ -34,45 +44,114 @@ async function askPassword() {
34
44
  process.stdin.setRawMode(false);
35
45
  process.stdin.pause();
36
46
  process.stderr.write("\n");
37
- return resolve(pw);
47
+ return resolve(secret);
38
48
  }
39
49
  if (ch === "\u0003")
40
50
  process.exit(130);
41
- pw = ch === "\u007f" ? pw.slice(0, -1) : pw + ch;
51
+ secret = ch === "\u007f" ? secret.slice(0, -1) : secret + ch;
42
52
  }
43
53
  });
44
54
  });
45
55
  }
46
- const fromB64u = (s) => new TextDecoder().decode(Uint8Array.from(atob(s.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0)));
56
+ /** Loads a provider module: a path (./my-psc.mjs) or a package name; default export or `provider`. */
57
+ async function loadProvider(spec) {
58
+ const url = spec.startsWith(".") || spec.startsWith("/") ? pathToFileURL(resolve(spec)).href : spec;
59
+ const mod = (await import(url));
60
+ const p = (mod.provider ?? mod.default);
61
+ if (!p || typeof p.authorizationUrl !== "function" || typeof p.exchangeCode !== "function")
62
+ die(`${spec} does not export a CloudSignatureProvider (default export or "provider")`);
63
+ return p;
64
+ }
65
+ /**
66
+ * OAuth with PKCE through the browser: a one-time server on 127.0.0.1 receives the code, checks the state,
67
+ * exchanges it and returns an e-CNPJ signer over the provider's session.
68
+ */
69
+ async function cloudLogin(provider, port, open) {
70
+ const state = newState();
71
+ const { verifier, challenge } = await pkcePair();
72
+ let answer;
73
+ const answered = new Promise((r) => (answer = r));
74
+ const server = createServer((req, res) => {
75
+ const u = new URL(req.url ?? "/", "http://127.0.0.1");
76
+ if (u.pathname !== "/callback")
77
+ return void res.writeHead(404).end();
78
+ const ok = u.searchParams.get("state") === state && !!u.searchParams.get("code");
79
+ res.writeHead(ok ? 200 : 400, { "content-type": "text/html; charset=utf-8" }).end(ok ? "<p>Autorizado. Pode fechar esta janela e voltar ao terminal.</p>" : "<p>Autorização recusada ou inválida.</p>");
80
+ answer(ok ? { code: u.searchParams.get("code") } : { error: u.searchParams.get("error_description") ?? u.searchParams.get("error") ?? "state mismatch" });
81
+ });
82
+ await new Promise((r) => server.listen(port, "127.0.0.1", r));
83
+ const addr = server.address();
84
+ const redirectUri = `http://127.0.0.1:${typeof addr === "object" && addr ? addr.port : port}/callback`;
85
+ const timer = setTimeout(() => answer({ error: "no approval within 5 minutes" }), 300_000);
86
+ try {
87
+ const url = await provider.authorizationUrl({ redirectUri, state, codeChallenge: challenge });
88
+ process.stderr.write(`Abra para aprovar no ${provider.name} / open to approve:\n ${url}\n`);
89
+ if (open) {
90
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
91
+ const a = process.platform === "win32" ? ["/c", "start", "", url] : [url];
92
+ spawn(cmd, a, { stdio: "ignore", detached: true }).on("error", () => undefined).unref();
93
+ }
94
+ const r = await answered;
95
+ if (!r.code)
96
+ throw new Error(`not authorized: ${r.error}`);
97
+ const session = await provider.exchangeCode({ code: r.code, redirectUri, codeVerifier: verifier });
98
+ const signer = await cloudSigner(session);
99
+ return { ...signer, close: async () => void (await session.close?.().catch(() => undefined)) };
100
+ }
101
+ finally {
102
+ clearTimeout(timer);
103
+ server.close();
104
+ }
105
+ }
106
+ const USAGE = `usage:
107
+ kya-sign --pfx <file.pfx> (--link <signing link> | --text-file <file> | --text <text>)
108
+ kya-sign --pkcs11 <library> [--slot N] [--label L] (--link … | --text-file … | --text …)
109
+ kya-sign --pkcs11 <library> --list
110
+ kya-sign --cloud <provider module> [--port N] [--no-open] (--link … | --text-file … | --text …)
111
+ `;
47
112
  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);
113
+ if (args.includes("--help") || args.includes("-h"))
114
+ return void process.stdout.write(USAGE);
115
+ const pfxPath = opt("pfx");
116
+ const library = opt("pkcs11");
117
+ const cloud = opt("cloud");
118
+ if (!pfxPath && !library && !cloud) {
119
+ process.stdout.write(USAGE);
120
+ process.exit(2);
51
121
  }
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`);
122
+ const slot = opt("slot") !== undefined ? Number(opt("slot")) : undefined;
123
+ if (library && args.includes("--list")) {
124
+ for (const c of await listTokenCertificates({ library, slot }))
125
+ process.stdout.write(`${c.label}\t${c.cnpj ?? "-"}\t${c.notAfter.toISOString().slice(0, 10)}\t${c.subject}\n`);
66
126
  return;
67
127
  }
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);
128
+ const link = opt("link") ? readSigningLink(opt("link")) : null;
129
+ const text = link ? link.text : opt("text-file") ? readFileSync(opt("text-file"), "utf8") : (opt("text") ?? die("give --link, --text-file or --text"));
130
+ let signer;
131
+ if (cloud)
132
+ signer = await cloudLogin(await loadProvider(cloud), opt("port") ? Number(opt("port")) : 0, !args.includes("--no-open"));
133
+ else if (library)
134
+ signer = await openToken({ library, slot, label: opt("label"), pin: await askSecret("KYA_TOKEN_PIN", "PIN do token / token PIN: ") });
135
+ else
136
+ signer = await openPfx(new Uint8Array(readFileSync(pfxPath)), await askSecret("KYA_PFX_PASSWORD", "Senha do certificado / certificate password: "));
137
+ try {
138
+ process.stderr.write(`certificate: ${signer.subject}\nsigning:\n${text.split("\n").map((l) => ` ${l}`).join("\n")}\n`);
139
+ const signature = await signer.sign(text);
140
+ if (!link?.credentialId || !link.challengeId || !link.agentSignature) {
141
+ process.stdout.write(`${signature}\n`);
142
+ return;
143
+ }
144
+ const res = await fetch(new URL(`/v1/agents/${encodeURIComponent(link.credentialId)}/prove`, link.issuer), {
145
+ method: "POST",
146
+ headers: { "content-type": "application/json" },
147
+ body: JSON.stringify({ challenge_id: link.challengeId, agent_signature: link.agentSignature, ecnpj_signature: signature })
148
+ });
149
+ process.stdout.write(`${await res.text()}\n`);
150
+ if (!res.ok)
151
+ process.exitCode = 1;
152
+ }
153
+ finally {
154
+ await signer.close?.();
155
+ }
77
156
  }
78
157
  main().catch((e) => die(e.message));
package/dist/index.d.ts CHANGED
@@ -141,6 +141,8 @@ export interface Registration {
141
141
  pt: string;
142
142
  };
143
143
  mandate_schema: string;
144
+ /** Link for the e-CNPJ holder; append &agent_signature=… (or use signingLink / signLink). */
145
+ sign_url?: string;
144
146
  }
145
147
  export interface Proof {
146
148
  challenge_id: string;
@@ -204,6 +206,16 @@ export interface KyaClient {
204
206
  warnings?: string[]; /** /v1 compatibility, same as warnings. */
205
207
  kyb_warnings: string[];
206
208
  }>;
209
+ /** Free: the link for the e-CNPJ holder (owner, accountant), with the agent's signature already in it. */
210
+ signLink(credentialId: string, q: {
211
+ challenge_id: string;
212
+ agent_signature?: string;
213
+ }): Promise<{
214
+ credential_id: string;
215
+ challenge_id: string;
216
+ expires_at: string;
217
+ sign_url: string;
218
+ }>;
207
219
  /** $0.10: renewal challenge (then renewProve). */
208
220
  renew(credentialId: string): Promise<{
209
221
  credential_id: string;
@@ -361,7 +373,8 @@ export declare function verifyCredentialOffline(credential: Credential, opts?: {
361
373
  now?: Date;
362
374
  }): Promise<OfflineCheck>;
363
375
  export * from "./auth.js";
364
- export * from "./pfx.js";
376
+ export { buildDetachedCms, cnpjFromCommonName, ecnpjSigner, openPfx, readSigningLink, signWithECnpjPfx, type ECnpjSigner, type RawSigner } from "@kya-brasil/core";
377
+ export { listTokenCertificates, openToken, type TokenCertificate, type TokenOptions } from "./pkcs11.js";
365
378
  /**
366
379
  * Link to the issuer's signing page (<issuer>/sign) for the person who holds the company's e-CNPJ, usually the
367
380
  * owner or the accountant. The agent registers, signs its half and sends this link; the person opens it, sees
package/dist/index.js CHANGED
@@ -88,6 +88,7 @@ export function createKyaClient(opts = {}) {
88
88
  allowed: (id, a) => call("GET", `/v1/agents/${encodeURIComponent(id)}/allowed${q(a)}`, undefined, true, 0.01),
89
89
  register: (input) => call("POST", "/v1/agents", input, true, 0.25),
90
90
  prove: (id, proof) => call("POST", `/v1/agents/${encodeURIComponent(id)}/prove`, proof),
91
+ signLink: (id, a) => call("GET", `/v1/agents/${encodeURIComponent(id)}/sign-link${q(a)}`),
91
92
  renew: (id) => call("POST", `/v1/agents/${encodeURIComponent(id)}/renew`, {}, true, 0.1),
92
93
  renewProve: (id, proof) => call("POST", `/v1/agents/${encodeURIComponent(id)}/renew/prove`, proof),
93
94
  revoke: (id, input) => call("POST", `/v1/agents/${encodeURIComponent(id)}/revoke`, input),
@@ -250,7 +251,10 @@ export async function verifyCredentialOffline(credential, opts = {}) {
250
251
  return { signature, within_validity, live_status: "unknown", usable_offline: signature.valid && within_validity && credential.status === "active" };
251
252
  }
252
253
  export * from "./auth.js";
253
- export * from "./pfx.js";
254
+ // e-CNPJ signing (D-26, D-27): the implementation lives in @kya-brasil/core, shared with anyone using the library.
255
+ export { buildDetachedCms, cnpjFromCommonName, ecnpjSigner, openPfx, readSigningLink, signWithECnpjPfx } from "@kya-brasil/core";
256
+ export { listTokenCertificates, openToken } from "./pkcs11.js";
257
+ import { signingLink as coreSigningLink } from "@kya-brasil/core";
254
258
  /**
255
259
  * Link to the issuer's signing page (<issuer>/sign) for the person who holds the company's e-CNPJ, usually the
256
260
  * owner or the accountant. The agent registers, signs its half and sends this link; the person opens it, sees
@@ -258,11 +262,5 @@ export * from "./pfx.js";
258
262
  * leaves the computer) or uploads a .p7s from any ICP-Brasil signer, and the credential is issued.
259
263
  */
260
264
  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();
265
+ return coreSigningLink({ issuer: p.baseUrl ?? DEFAULT_BASE_URL, credentialId: p.credentialId, challenge: p.challenge, agentSignature: p.agentSignature });
268
266
  }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * A3 e-CNPJ (token or smart card) through PKCS#11, the interface every ICP-Brasil token driver ships
3
+ * (e.g. SafeNet eToken: libeTPkcs11.so / eTPKCS11.dll; GD/Watchdata/Oberthur: their own libraries). The
4
+ * private key never leaves the device: only the bytes to sign go in and the signature comes out. Node only;
5
+ * needs the optional package pkcs11js (npm install pkcs11js) and the vendor's library path.
6
+ */
7
+ import { type ECnpjSigner } from "@kya-brasil/core";
8
+ export interface TokenOptions {
9
+ /** Path to the vendor's PKCS#11 library (.so / .dylib / .dll). */
10
+ library: string;
11
+ /** The token's user PIN. */
12
+ pin: string;
13
+ /** Index among the slots with a token present (default 0). */
14
+ slot?: number;
15
+ /** Pick the certificate by its label on the token; default: the one whose common name carries a CNPJ. */
16
+ label?: string;
17
+ }
18
+ export interface TokenCertificate {
19
+ label: string;
20
+ subject: string;
21
+ cnpj: string | null;
22
+ notAfter: Date;
23
+ }
24
+ /** Certificates on the token (no PIN needed on most tokens), to pick one with `label`. */
25
+ export declare function listTokenCertificates(o: {
26
+ library: string;
27
+ slot?: number;
28
+ pin?: string;
29
+ }): Promise<TokenCertificate[]>;
30
+ /**
31
+ * Opens the token and returns an e-CNPJ signer (same interface as openPfx). Call close() when done; it logs out
32
+ * and releases the token.
33
+ */
34
+ export declare function openToken(o: TokenOptions): Promise<ECnpjSigner & {
35
+ close(): void;
36
+ }>;
package/dist/pkcs11.js ADDED
@@ -0,0 +1,124 @@
1
+ /**
2
+ * A3 e-CNPJ (token or smart card) through PKCS#11, the interface every ICP-Brasil token driver ships
3
+ * (e.g. SafeNet eToken: libeTPkcs11.so / eTPKCS11.dll; GD/Watchdata/Oberthur: their own libraries). The
4
+ * private key never leaves the device: only the bytes to sign go in and the signature comes out. Node only;
5
+ * needs the optional package pkcs11js (npm install pkcs11js) and the vendor's library path.
6
+ */
7
+ import { ecnpjSigner } from "@kya-brasil/core";
8
+ // DigestInfo prefix for SHA-256 (RFC 8017), for tokens that only offer raw CKM_RSA_PKCS.
9
+ const SHA256_DIGEST_INFO = Uint8Array.from([0x30, 0x31, 0x30, 0x0d, 0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01, 0x05, 0x00, 0x04, 0x20]);
10
+ async function load() {
11
+ try {
12
+ const mod = (await import("pkcs11js"));
13
+ return mod.default ?? mod;
14
+ }
15
+ catch {
16
+ throw new Error("A3 tokens need the optional package pkcs11js: npm install pkcs11js");
17
+ }
18
+ }
19
+ async function openSession(o) {
20
+ const p11 = await load();
21
+ const lib = new p11.PKCS11();
22
+ lib.load(o.library);
23
+ lib.C_Initialize();
24
+ const finalize = () => {
25
+ try {
26
+ lib.C_Finalize();
27
+ }
28
+ catch {
29
+ /* already finalized */
30
+ }
31
+ };
32
+ try {
33
+ const slots = lib.C_GetSlotList(true);
34
+ const slot = slots[o.slot ?? 0];
35
+ if (!slot)
36
+ throw new Error(slots.length ? `no token in slot ${o.slot}` : "no token found: is it plugged in, and is this the right library?");
37
+ const session = lib.C_OpenSession(slot, p11.CKF_SERIAL_SESSION);
38
+ if (o.pin !== undefined)
39
+ lib.C_Login(session, p11.CKU_USER, o.pin);
40
+ lib.C_FindObjectsInit(session, [{ type: p11.CKA_CLASS, value: p11.CKO_CERTIFICATE }]);
41
+ const handles = lib.C_FindObjects(session, 64);
42
+ lib.C_FindObjectsFinal(session);
43
+ const certs = handles.map((handle) => {
44
+ const [value, id, label] = lib.C_GetAttributeValue(session, handle, [{ type: p11.CKA_VALUE }, { type: p11.CKA_ID }, { type: p11.CKA_LABEL }]);
45
+ const der = new Uint8Array(value.value);
46
+ let signer = null;
47
+ try {
48
+ signer = ecnpjSigner({ certificates: [der], sign: async () => new Uint8Array() });
49
+ }
50
+ catch {
51
+ /* not an X.509 certificate we can read */
52
+ }
53
+ return { handle, der, id: id.value ?? Buffer.alloc(0), label: label.value?.toString("utf8") ?? "", signer };
54
+ });
55
+ return {
56
+ p11,
57
+ lib,
58
+ session,
59
+ certs,
60
+ close() {
61
+ try {
62
+ if (o.pin !== undefined)
63
+ lib.C_Logout(session);
64
+ lib.C_CloseSession(session);
65
+ }
66
+ catch {
67
+ /* ignore */
68
+ }
69
+ finalize();
70
+ }
71
+ };
72
+ }
73
+ catch (e) {
74
+ finalize();
75
+ throw e;
76
+ }
77
+ }
78
+ /** Certificates on the token (no PIN needed on most tokens), to pick one with `label`. */
79
+ export async function listTokenCertificates(o) {
80
+ const s = await openSession(o);
81
+ try {
82
+ return s.certs.filter((c) => c.signer).map((c) => ({ label: c.label, subject: c.signer.subject, cnpj: c.signer.cnpj, notAfter: c.signer.notAfter }));
83
+ }
84
+ finally {
85
+ s.close();
86
+ }
87
+ }
88
+ /**
89
+ * Opens the token and returns an e-CNPJ signer (same interface as openPfx). Call close() when done; it logs out
90
+ * and releases the token.
91
+ */
92
+ export async function openToken(o) {
93
+ const s = await openSession(o);
94
+ try {
95
+ const { p11, lib, session } = s;
96
+ const cert = o.label ? s.certs.find((c) => c.label === o.label) : s.certs.find((c) => c.signer?.cnpj);
97
+ if (!cert?.signer)
98
+ throw new Error(o.label ? `no certificate labelled "${o.label}" on the token` : "no e-CNPJ certificate on the token (none with a CNPJ in its name); pass a label");
99
+ lib.C_FindObjectsInit(session, [{ type: p11.CKA_CLASS, value: p11.CKO_PRIVATE_KEY }, { type: p11.CKA_ID, value: cert.id }]);
100
+ const [key] = lib.C_FindObjects(session, 1);
101
+ lib.C_FindObjectsFinal(session);
102
+ if (!key)
103
+ throw new Error("the token has no private key for that certificate (wrong PIN or key not on this token)");
104
+ // The chain: the other certificates on the token, when any (the issuer also has the ICP-Brasil bundle).
105
+ const chain = s.certs.filter((c) => c !== cert && c.signer).map((c) => c.der);
106
+ const signRaw = async (data) => {
107
+ try {
108
+ lib.C_SignInit(session, { mechanism: p11.CKM_SHA256_RSA_PKCS }, key);
109
+ return new Uint8Array(lib.C_Sign(session, Buffer.from(data), Buffer.alloc(1024)));
110
+ }
111
+ catch {
112
+ // Older tokens: hash here and sign the DigestInfo with raw PKCS#1 v1.5.
113
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", data));
114
+ lib.C_SignInit(session, { mechanism: p11.CKM_RSA_PKCS }, key);
115
+ return new Uint8Array(lib.C_Sign(session, Buffer.concat([SHA256_DIGEST_INFO, digest]), Buffer.alloc(1024)));
116
+ }
117
+ };
118
+ return { ...ecnpjSigner({ certificates: [cert.der, ...chain], sign: signRaw }), close: () => s.close() };
119
+ }
120
+ catch (e) {
121
+ s.close();
122
+ throw e;
123
+ }
124
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kya-brasil/agent-tools",
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.",
3
+ "version": "0.9.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: A1 .pfx or A3 token via PKCS#11, 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,22 +54,24 @@
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",
58
57
  "pkijs": "^3.4.1",
59
- "viem": "^2.57.5"
58
+ "viem": "^2.57.5",
59
+ "@kya-brasil/core": "^0.9.0"
60
60
  },
61
61
  "devDependencies": {
62
- "@kya-brasil/core": "workspace:*",
63
62
  "@peculiar/asn1-schema": "^2.3.15",
64
63
  "@peculiar/asn1-x509": "^2.3.15",
65
64
  "@peculiar/x509": "^2.1.0",
66
65
  "@types/node": "^22.20.5",
67
- "@types/node-forge": "^1.3.14",
68
66
  "reflect-metadata": "^0.2.2",
69
67
  "typescript": "^5.9.3",
70
- "vitest": "^4.1.11"
68
+ "vitest": "^4.1.11",
69
+ "pkcs11js": "2.1.7"
71
70
  },
72
71
  "bin": {
73
72
  "kya-sign": "./dist/cli.js"
73
+ },
74
+ "optionalDependencies": {
75
+ "pkcs11js": "2.1.7"
74
76
  }
75
77
  }
package/dist/pfx.d.ts DELETED
@@ -1,13 +0,0 @@
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 DELETED
@@ -1,66 +0,0 @@
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
- }