@kya-brasil/core 0.7.0 → 0.8.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
@@ -66,6 +66,27 @@ the network already gives (network token reference, Payment Account Reference),
66
66
  With the hosted issuer, the same check comes back signed from
67
67
  `GET /v1/agents/{id}/allowed?payment_network=visa&payment_ref_sha256=…`, which is evidence for a later dispute.
68
68
 
69
+ ## Sign as the e-CNPJ holder (A1 and A3)
70
+
71
+ The company's side of the challenge, without the hosted page. The issuer only ever receives the signature.
72
+
73
+ ```ts
74
+ import { openPfx, ecnpjSigner, readSigningLink } from "@kya-brasil/core";
75
+
76
+ // A1: the .pfx / .p12 file (legacy 3DES/RC2 PKCS#12 included)
77
+ const a1 = await openPfx(pfxBytes, password);
78
+ const ecnpj_signature = await a1.sign(challengeText); // detached CMS, base64: POST it to /v1/agents/{id}/prove
79
+
80
+ // A3 token, smart card, HSM or cloud certificate: anything that signs bytes with the certificate's RSA key
81
+ // (PKCS#1 v1.5 over SHA-256), plus the certificate chain in DER.
82
+ const a3 = ecnpjSigner({ certificates: [certDer, ...chainDer], sign: (bytes) => myToken.sign("SHA256withRSA", bytes) });
83
+ const sig = await a3.sign(readSigningLink(link).text); // the text from a /sign link the agent sent
84
+ ```
85
+
86
+ `sign` refuses a text whose `cnpj:` line names another company. For PKCS#11 tokens on Node,
87
+ `openToken({ library, pin })` in `@kya-brasil/agent-tools` (and the `kya-sign --pkcs11` command) already wires
88
+ `C_Sign` into `ecnpjSigner`.
89
+
69
90
  ## Run your own issuer
70
91
 
71
92
  ```ts
@@ -0,0 +1,57 @@
1
+ /** Signs `data` with the certificate's RSA private key: PKCS#1 v1.5 over SHA-256 (the signer hashes). */
2
+ export type RawSigner = (data: Uint8Array) => Promise<Uint8Array>;
3
+ /**
4
+ * Detached CMS/PKCS#7 SignedData (base64 DER) over `text`, with signed attributes (content type, message
5
+ * digest, signing time) and the given certificates (signer first, then its chain, DER). The issuer accepts it as
6
+ * `ecnpj_signature`.
7
+ */
8
+ export declare function buildDetachedCms(p: {
9
+ text: string;
10
+ certificates: Uint8Array[];
11
+ sign: RawSigner;
12
+ signingTime?: Date;
13
+ }): Promise<string>;
14
+ /** What a certificate holder exposes to sign: subject, CNPJ, validity, chain and the sign step. */
15
+ export interface ECnpjSigner {
16
+ /** Subject of the e-CNPJ certificate, e.g. "CN=ACME LTDA:11222333000181, ...". */
17
+ subject: string;
18
+ /** CNPJ read from the certificate's common name ("NAME:CNPJ"), when present. */
19
+ cnpj: string | null;
20
+ notAfter: Date;
21
+ /** Signer certificate first, then the chain found with it (DER). */
22
+ certificates: Uint8Array[];
23
+ /** Detached CMS (base64 DER) over `text`; refuses a text whose "cnpj:" line names another company. */
24
+ sign(text: string): Promise<string>;
25
+ }
26
+ /** CNPJ in a common name ending in ":<14 digits>", the ICP-Brasil convention for e-CNPJ. */
27
+ export declare const cnpjFromCommonName: (cn: string) => string | null;
28
+ /** Wraps a raw signer and its certificates into an ECnpjSigner (used for A1 files, tokens, HSMs, cloud). */
29
+ export declare function ecnpjSigner(p: {
30
+ certificates: Uint8Array[];
31
+ sign: RawSigner;
32
+ }): ECnpjSigner;
33
+ /** Opens an A1 e-CNPJ file (.pfx / .p12 bytes) with its password. The key stays in memory, inside Web Crypto. */
34
+ export declare function openPfx(pfx: Uint8Array, password: string): Promise<ECnpjSigner>;
35
+ /** One call: sign `text` with the e-CNPJ in an A1 file. Returns the base64 DER the issuer expects as ecnpj_signature. */
36
+ export declare function signWithECnpjPfx(pfx: Uint8Array, password: string, text: string): Promise<string>;
37
+ /**
38
+ * Link to an issuer's signing page (<issuer>/sign) for the person who holds the company's e-CNPJ. The agent
39
+ * registers, signs its half and sends this link; the person sees what they authorize and signs in the browser.
40
+ */
41
+ export declare function signingLink(p: {
42
+ issuer: string;
43
+ credentialId: string;
44
+ challenge: {
45
+ id: string;
46
+ text: string;
47
+ };
48
+ agentSignature?: string;
49
+ }): string;
50
+ /** The challenge text carried by a signing link (inverse of signingLink), with its ids. */
51
+ export declare function readSigningLink(link: string): {
52
+ issuer: string;
53
+ credentialId: string | null;
54
+ challengeId: string | null;
55
+ agentSignature: string | null;
56
+ text: string;
57
+ };
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Signing with the company's e-CNPJ, for whoever holds it (D-26, D-27). One CMS builder for every kind of
3
+ * certificate: the caller gives the certificate chain and a function that signs bytes with the private key.
4
+ * A1 (.pfx / .p12 file): openPfx / signWithECnpjPfx here (node-forge reads the legacy 3DES/RC2 PKCS#12
5
+ * many Brazilian A1 files use; the key is then used through Web Crypto);
6
+ * A3 (token, smart card), HSM, cloud certificate: anything that can produce an RSA PKCS#1 v1.5 SHA-256
7
+ * signature (PKCS#11 C_Sign with CKM_SHA256_RSA_PKCS, a cloud signing API, ...) plugs into
8
+ * buildDetachedCms. The kya-sign command in @kya-brasil/agent-tools does it for PKCS#11 tokens.
9
+ * Pure: no storage, no network. The issuer never receives the certificate or its password.
10
+ */
11
+ import * as asn1js from "asn1js";
12
+ import * as pkijs from "pkijs";
13
+ import forge from "node-forge";
14
+ import { KyaError } from "./types.js";
15
+ const OID_DATA = "1.2.840.113549.1.7.1";
16
+ const OID_SIGNED_DATA = "1.2.840.113549.1.7.2";
17
+ const OID_SHA256 = "2.16.840.1.101.3.4.2.1";
18
+ const OID_SHA256_RSA = "1.2.840.113549.1.1.11";
19
+ const OID_CONTENT_TYPE = "1.2.840.113549.1.9.3";
20
+ const OID_MESSAGE_DIGEST = "1.2.840.113549.1.9.4";
21
+ const OID_SIGNING_TIME = "1.2.840.113549.1.9.5";
22
+ const toArrayBuffer = (u) => u.buffer.slice(u.byteOffset, u.byteOffset + u.byteLength);
23
+ function ensureEngine() {
24
+ pkijs.setEngine("kya-sign", new pkijs.CryptoEngine({ name: "kya-sign", crypto: globalThis.crypto }));
25
+ }
26
+ /**
27
+ * Detached CMS/PKCS#7 SignedData (base64 DER) over `text`, with signed attributes (content type, message
28
+ * digest, signing time) and the given certificates (signer first, then its chain, DER). The issuer accepts it as
29
+ * `ecnpj_signature`.
30
+ */
31
+ export async function buildDetachedCms(p) {
32
+ ensureEngine();
33
+ if (!p.certificates.length)
34
+ throw new KyaError("INVALID_INPUT", "no signer certificate");
35
+ const certs = p.certificates.map((der) => pkijs.Certificate.fromBER(toArrayBuffer(der)));
36
+ const leaf = certs[0];
37
+ const content = new TextEncoder().encode(p.text);
38
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", content));
39
+ const signedAttrs = new pkijs.SignedAndUnsignedAttributes({
40
+ type: 0,
41
+ attributes: [
42
+ new pkijs.Attribute({ type: OID_CONTENT_TYPE, values: [new asn1js.ObjectIdentifier({ value: OID_DATA })] }),
43
+ new pkijs.Attribute({ type: OID_MESSAGE_DIGEST, values: [new asn1js.OctetString({ valueHex: toArrayBuffer(digest) })] }),
44
+ new pkijs.Attribute({ type: OID_SIGNING_TIME, values: [new asn1js.UTCTime({ valueDate: p.signingTime ?? new Date() })] })
45
+ ]
46
+ });
47
+ // What is signed is the DER of the attributes as a SET (tag 0x31), not the [0] IMPLICIT form they travel in.
48
+ const toBeSigned = new Uint8Array(signedAttrs.toSchema().toBER(false));
49
+ toBeSigned[0] = 0x31;
50
+ const signature = await p.sign(toBeSigned);
51
+ const signerInfo = new pkijs.SignerInfo({
52
+ version: 1,
53
+ sid: new pkijs.IssuerAndSerialNumber({ issuer: leaf.issuer, serialNumber: leaf.serialNumber }),
54
+ digestAlgorithm: new pkijs.AlgorithmIdentifier({ algorithmId: OID_SHA256 }),
55
+ signedAttrs,
56
+ signatureAlgorithm: new pkijs.AlgorithmIdentifier({ algorithmId: OID_SHA256_RSA }),
57
+ signature: new asn1js.OctetString({ valueHex: toArrayBuffer(signature) })
58
+ });
59
+ const signed = new pkijs.SignedData({
60
+ version: 1,
61
+ digestAlgorithms: [new pkijs.AlgorithmIdentifier({ algorithmId: OID_SHA256 })],
62
+ encapContentInfo: new pkijs.EncapsulatedContentInfo({ eContentType: OID_DATA }),
63
+ certificates: certs,
64
+ signerInfos: [signerInfo]
65
+ });
66
+ const ci = new pkijs.ContentInfo({ contentType: OID_SIGNED_DATA, content: signed.toSchema(true) });
67
+ const der = new Uint8Array(ci.toSchema().toBER(false));
68
+ let bin = "";
69
+ for (const b of der)
70
+ bin += String.fromCharCode(b);
71
+ return btoa(bin);
72
+ }
73
+ /** CNPJ in a common name ending in ":<14 digits>", the ICP-Brasil convention for e-CNPJ. */
74
+ export const cnpjFromCommonName = (cn) => /:(\d{14})\s*$/.exec(cn)?.[1] ?? null;
75
+ /** Wraps a raw signer and its certificates into an ECnpjSigner (used for A1 files, tokens, HSMs, cloud). */
76
+ export function ecnpjSigner(p) {
77
+ const leaf = forge.pki.certificateFromAsn1(forge.asn1.fromDer(forge.util.binary.raw.encode(p.certificates[0])));
78
+ const cn = String(leaf.subject.getField("CN")?.value ?? "");
79
+ const cnpj = cnpjFromCommonName(cn);
80
+ return {
81
+ subject: leaf.subject.attributes.map((a) => `${a.shortName ?? a.name}=${a.value}`).join(", "),
82
+ cnpj,
83
+ notAfter: leaf.validity.notAfter,
84
+ certificates: p.certificates,
85
+ async sign(text) {
86
+ const asked = /^cnpj: (\d{14})$/m.exec(text)?.[1];
87
+ if (asked && cnpj && asked !== cnpj)
88
+ throw new KyaError("CNPJ_MISMATCH", `this certificate is for CNPJ ${cnpj}, the text is for ${asked}`);
89
+ return buildDetachedCms({ text, certificates: p.certificates, sign: p.sign });
90
+ }
91
+ };
92
+ }
93
+ const certDer = (c) => new Uint8Array(forge.util.binary.raw.decode(forge.asn1.toDer(forge.pki.certificateToAsn1(c)).getBytes()));
94
+ /** Opens an A1 e-CNPJ file (.pfx / .p12 bytes) with its password. The key stays in memory, inside Web Crypto. */
95
+ export async function openPfx(pfx, password) {
96
+ let p12;
97
+ try {
98
+ p12 = forge.pkcs12.pkcs12FromAsn1(forge.asn1.fromDer(forge.util.binary.raw.encode(pfx)), false, password);
99
+ }
100
+ catch (e) {
101
+ throw new KyaError("INVALID_INPUT", `could not open the certificate file (wrong password or not a .pfx/.p12): ${e.message}`);
102
+ }
103
+ const oids = forge.pki.oids;
104
+ const keyBag = [oids.pkcs8ShroudedKeyBag, oids.keyBag].flatMap((t) => p12.getBags({ bagType: t })[t] ?? []).find((b) => b.key);
105
+ if (!keyBag?.key)
106
+ throw new KyaError("INVALID_INPUT", "the certificate file has no private key");
107
+ const key = keyBag.key;
108
+ const certs = (p12.getBags({ bagType: oids.certBag })[oids.certBag] ?? []).map((b) => b.cert).filter((c) => !!c);
109
+ // The signer is the certificate whose public key matches the private key; the others are its chain.
110
+ const leaf = certs.find((c) => c.publicKey.n?.equals(key.n));
111
+ if (!leaf)
112
+ throw new KyaError("INVALID_INPUT", "no certificate in the file matches its private key");
113
+ const pkcs8 = forge.util.binary.raw.decode(forge.asn1.toDer(forge.pki.wrapRsaPrivateKey(forge.pki.privateKeyToAsn1(key))).getBytes());
114
+ const cryptoKey = await crypto.subtle.importKey("pkcs8", toArrayBuffer(pkcs8), { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, ["sign"]);
115
+ return ecnpjSigner({
116
+ certificates: [leaf, ...certs.filter((c) => c !== leaf)].map(certDer),
117
+ sign: async (data) => new Uint8Array(await crypto.subtle.sign({ name: "RSASSA-PKCS1-v1_5" }, cryptoKey, toArrayBuffer(data)))
118
+ });
119
+ }
120
+ /** One call: sign `text` with the e-CNPJ in an A1 file. Returns the base64 DER the issuer expects as ecnpj_signature. */
121
+ export async function signWithECnpjPfx(pfx, password, text) {
122
+ return (await openPfx(pfx, password)).sign(text);
123
+ }
124
+ /**
125
+ * Link to an issuer's signing page (<issuer>/sign) for the person who holds the company's e-CNPJ. The agent
126
+ * registers, signs its half and sends this link; the person sees what they authorize and signs in the browser.
127
+ */
128
+ export function signingLink(p) {
129
+ const u = new URL("/sign", p.issuer);
130
+ u.searchParams.set("id", p.credentialId);
131
+ u.searchParams.set("challenge_id", p.challenge.id);
132
+ let bin = "";
133
+ for (const b of new TextEncoder().encode(p.challenge.text))
134
+ bin += String.fromCharCode(b);
135
+ u.searchParams.set("text", btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""));
136
+ if (p.agentSignature)
137
+ u.searchParams.set("agent_signature", p.agentSignature);
138
+ return u.toString();
139
+ }
140
+ /** The challenge text carried by a signing link (inverse of signingLink), with its ids. */
141
+ export function readSigningLink(link) {
142
+ const u = new URL(link);
143
+ const t = u.searchParams.get("text");
144
+ if (!t)
145
+ throw new KyaError("INVALID_INPUT", "the signing link has no text");
146
+ const bin = atob(t.replace(/-/g, "+").replace(/_/g, "/"));
147
+ return {
148
+ issuer: u.origin,
149
+ credentialId: u.searchParams.get("id"),
150
+ challengeId: u.searchParams.get("challenge_id"),
151
+ agentSignature: u.searchParams.get("agent_signature"),
152
+ text: new TextDecoder().decode(Uint8Array.from(bin, (c) => c.charCodeAt(0)))
153
+ };
154
+ }
@@ -87,6 +87,15 @@ export async function verifyIcpSignature(p) {
87
87
  throw new KyaError("CERTIFICATE_INVALID", "CMS carries no signer certificate");
88
88
  const trusted = p.anchors.map((a) => pkijs.Certificate.fromBER(unb64(a.replace(/-----[^-]+-----/g, ""))));
89
89
  const data = new TextEncoder().encode(p.challenge);
90
+ // An attached CMS (the .p7s carries the signed text) is verified over its own content by pkijs, whatever
91
+ // `data` says: it MUST carry exactly the challenge, or any document the company ever signed would pass.
92
+ const embedded = signed.encapContentInfo.eContent;
93
+ if (embedded) {
94
+ const content = new Uint8Array(embedded.getValue());
95
+ if (content.length !== data.length || !content.every((b, i) => b === data[i])) {
96
+ throw new KyaError("CHALLENGE_FAILED", "the signed content is not the challenge text (sign exactly the challenge, without extra line breaks)");
97
+ }
98
+ }
90
99
  let ok = false;
91
100
  try {
92
101
  ok = await signed.verify({ signer: 0, data: data.buffer, trustedCerts: trusted, checkChain: false, checkDate: now });
package/dist/index.d.ts CHANGED
@@ -7,3 +7,4 @@ export * from "./credential.js";
7
7
  export * from "./icp-brasil.js";
8
8
  export * from "./revocation.js";
9
9
  export * from "./erc8004.js";
10
+ export * from "./ecnpj-sign.js";
package/dist/index.js CHANGED
@@ -7,3 +7,4 @@ export * from "./credential.js";
7
7
  export * from "./icp-brasil.js";
8
8
  export * from "./revocation.js";
9
9
  export * from "./erc8004.js";
10
+ export * from "./ecnpj-sign.js";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kya-brasil/core",
3
- "version": "0.7.0",
4
- "description": "KYA Brasil as a library: verify and issue know-your-agent credentials for Brazilian companies without any hosted service. e-CNPJ (ICP-Brasil) CMS signature and chain validation, CRL revocation, CNPJ, agent key challenges (EVM, Ed25519 / Web Bot Auth, ERC-8004), mandate schema and evaluation (including payment instruments for card networks and acquirers), credential build, Ed25519 signing and verification. Pure functions: storage and network are yours.",
3
+ "version": "0.8.0",
4
+ "description": "KYA Brasil as a library: verify and issue know-your-agent credentials for Brazilian companies without any hosted service. e-CNPJ (ICP-Brasil) CMS signature and chain validation, CRL revocation, CNPJ, agent key challenges (EVM, Ed25519 / Web Bot Auth, ERC-8004), mandate schema and evaluation (including payment instruments for card networks and acquirers), credential build, Ed25519 signing and verification, and e-CNPJ signing for the holder (A1 .pfx, or any token/HSM/cloud signer through a raw-sign callback). Pure functions: storage and network are yours.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "exports": {
@@ -57,12 +57,14 @@
57
57
  "@peculiar/asn1-x509": "^2.3.15",
58
58
  "@peculiar/x509": "^2.1.0",
59
59
  "asn1js": "^3.0.6",
60
+ "node-forge": "1.4.0",
60
61
  "pkijs": "^3.4.1",
61
62
  "reflect-metadata": "^0.2.2",
62
63
  "viem": "^2.57.5"
63
64
  },
64
65
  "devDependencies": {
65
66
  "@types/node": "^22.20.5",
67
+ "@types/node-forge": "^1.3.14",
66
68
  "typescript": "^5.9.3",
67
69
  "vitest": "^4.1.11"
68
70
  },