@kya-brasil/core 0.18.0 → 0.19.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
@@ -106,6 +106,23 @@ mandate's `valid_until` nor the certificate's end of validity. To revoke before
106
106
  certificate or issues mandates with short lifetimes; there is no status service to ask. The certificate signer
107
107
  stays outside the core: an A3 token (PKCS#11 and PIN) is `openToken` in `@kya-brasil/agent-tools`.
108
108
 
109
+ **Co-signature by a legal representative (SPEC section 21).** Holding the company's e-CNPJ does not make someone its
110
+ representative. A self-issued company credential may carry co-signatures by e-CPF over the same text; the verifier
111
+ supplies the representatives (the QSA, from its own source) and the core counts them:
112
+
113
+ ```ts
114
+ import { addCosignature, verifyCompanyWill } from "@kya-brasil/core";
115
+
116
+ const repCms = await representativeSigner.sign(proof.text); // e-CPF, the SAME text
117
+ const cosigned = await addCosignature({ credential, proof }, repCms, [], { anchors: icpBrasilAnchors, now: new Date() });
118
+ const will = await verifyCompanyWill(cosigned, { anchors: icpBrasilAnchors, now: new Date(), representatives: (cnpj) => qsaCpfs(cnpj), minCosigners: 1 });
119
+ // will.ok, or will.code: NO_COSIGNATURE | COSIGNER_NOT_REPRESENTATIVE | COSIGNATURE_TEXT_MISMATCH |
120
+ // COSIGNER_CERTIFICATE_INVALID | COSIGNATURE_NOT_RECORDED | INSUFFICIENT_COSIGNERS | COMPANY_SIGNATURE_INVALID
121
+ ```
122
+
123
+ The credential records only the e-CPF certificate's hash, issuer and dates; the CMS (which carries the certificate,
124
+ and so the CPF) travels in the proof (`proof.cosignatures`, also in `KYA-Principal-Proof`). The result has no CPF.
125
+
109
126
  Delegation, issued by the parent agent with its own key:
110
127
 
111
128
  ```ts
@@ -0,0 +1,50 @@
1
+ import { type IssuedCredential } from "./issuance.js";
2
+ /**
3
+ * Adds a legal representative's co-signature to a self-issued company credential: `cms` is a CMS by an e-CPF over
4
+ * `issued.proof.text` (the same bytes the e-CNPJ signed), `certChain` the chain to add when the CMS lacks it.
5
+ * Verifies the signature, the certificate's dates and chain up to `anchors`, and refuses an e-CNPJ, another text,
6
+ * a non-company or non-self-issued credential, and a certificate that already co-signed. Returns a new
7
+ * IssuedCredential: the credential gains a `cosignatures` entry (hash, issuer, dates; no CPF), the proof the CMS.
8
+ * Throws KyaError (INVALID_INPUT, CHALLENGE_FAILED, CERTIFICATE_INVALID).
9
+ */
10
+ export declare function addCosignature(issued: IssuedCredential, cms: string, certChain: (Uint8Array | string)[], opts: {
11
+ anchors: string[];
12
+ now: Date;
13
+ }): Promise<IssuedCredential>;
14
+ export type CompanyWillCode = "COMPANY_SIGNATURE_INVALID" | "NO_COSIGNATURE" | "COSIGNATURE_TEXT_MISMATCH" | "COSIGNER_CERTIFICATE_INVALID" | "COSIGNATURE_NOT_RECORDED" | "COSIGNER_NOT_REPRESENTATIVE" | "INSUFFICIENT_COSIGNERS";
15
+ export interface CosignerCheck {
16
+ /** Hash of the co-signer's e-CPF certificate (null when the CMS did not verify). Never the CPF. */
17
+ certificate_sha256: string | null;
18
+ ok: boolean;
19
+ representative: boolean;
20
+ code?: CompanyWillCode;
21
+ detail: string;
22
+ /** For the caller's revocation check of the e-CPF certificate (checkRevocation); null when it did not verify. */
23
+ revocation: {
24
+ serial_number: string;
25
+ issuer_certificate: string | null;
26
+ crl_urls: string[];
27
+ } | null;
28
+ }
29
+ export interface CompanyWillResult {
30
+ ok: boolean;
31
+ /** Why it was refused; absent when accepted. */
32
+ code?: CompanyWillCode;
33
+ detail: string;
34
+ /** Distinct representatives whose valid co-signature was counted. */
35
+ representatives_counted: number;
36
+ cosigners: CosignerCheck[];
37
+ }
38
+ /**
39
+ * Whether a self-issued company credential expresses the company's will: the e-CNPJ proof verifies
40
+ * (verifySelfIssuedCredential) AND at least `minCosigners` (default 1) distinct CPFs, read from valid e-CPF
41
+ * co-signatures over the same text, are among `representatives(cnpj)` (the verifier's QSA list). Every co-signature
42
+ * in the proof must be recorded in the credential (by certificate hash) and vice versa. Never throws on a bad
43
+ * credential; the result carries no CPF. Revocation of the e-CPF certificates is the caller's (`revocation`).
44
+ */
45
+ export declare function verifyCompanyWill(issued: IssuedCredential, opts: {
46
+ anchors: string[];
47
+ now: Date;
48
+ representatives: (cnpj: string) => Promise<string[]> | string[];
49
+ minCosigners?: number;
50
+ }): Promise<CompanyWillResult>;
package/dist/cosign.js ADDED
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Co-signature by a legal representative (SPEC section 21). Whoever holds a company's e-CNPJ can sign a mandate,
3
+ * whether or not they represent the company (shown by the Alloy model). A self-issued company credential may
4
+ * therefore carry co-signatures: CMS signatures by e-CPF over the SAME text the e-CNPJ signed
5
+ * (mandateSigningInput). The verifier supplies who represents the company (the QSA, from its own source): the core
6
+ * never calls the Receita Federal.
7
+ *
8
+ * Privacy (SPEC section 12): the credential keeps only, per co-signature, the e-CPF certificate's hash, issuer and
9
+ * dates. The CMS (which carries the certificate, and so the CPF and name) travels in the proof the agent presents,
10
+ * as the principal's own CMS does (section 19); the CPF is read from it at verification and never returned.
11
+ *
12
+ * Pure: no network, no storage, the clock is the caller's.
13
+ */
14
+ import { normalizeCnpj } from "./cnpj.js";
15
+ import { verifyIcpPersonSignature } from "./icp-brasil.js";
16
+ import { SELF_ISSUER, verifySelfIssuedCredential, withCertificates } from "./issuance.js";
17
+ import { KyaError, isPersonPrincipal } from "./types.js";
18
+ const bad = (why) => new KyaError("INVALID_INPUT", why);
19
+ const validDate = (d) => d instanceof Date && !Number.isNaN(d.getTime());
20
+ /**
21
+ * Adds a legal representative's co-signature to a self-issued company credential: `cms` is a CMS by an e-CPF over
22
+ * `issued.proof.text` (the same bytes the e-CNPJ signed), `certChain` the chain to add when the CMS lacks it.
23
+ * Verifies the signature, the certificate's dates and chain up to `anchors`, and refuses an e-CNPJ, another text,
24
+ * a non-company or non-self-issued credential, and a certificate that already co-signed. Returns a new
25
+ * IssuedCredential: the credential gains a `cosignatures` entry (hash, issuer, dates; no CPF), the proof the CMS.
26
+ * Throws KyaError (INVALID_INPUT, CHALLENGE_FAILED, CERTIFICATE_INVALID).
27
+ */
28
+ export async function addCosignature(issued, cms, certChain, opts) {
29
+ if (!issued?.credential || !issued.proof || typeof issued.proof.text !== "string")
30
+ throw bad("issued: { credential, proof } from assembleCredential is required");
31
+ const c = issued.credential;
32
+ if (c.issuer !== SELF_ISSUER)
33
+ throw bad(`only a self-issued credential (${SELF_ISSUER}) takes co-signatures`);
34
+ if (!c.principal || isPersonPrincipal(c.principal))
35
+ throw bad("only a company credential takes co-signatures of its legal representatives");
36
+ if (!validDate(opts?.now))
37
+ throw bad("opts.now must be a valid Date (the clock is the caller's)");
38
+ if (!Array.isArray(opts.anchors) || !opts.anchors.length)
39
+ throw bad("opts.anchors: the ICP-Brasil trust anchors are required");
40
+ if (typeof cms !== "string" || !cms.trim())
41
+ throw bad("cms: the co-signature (base64 DER) is required");
42
+ if (!Array.isArray(certChain))
43
+ throw bad("certChain must be an array; [] when the CMS already carries it");
44
+ const full = withCertificates(cms, certChain);
45
+ const icp = await verifyIcpPersonSignature({ cmsBase64: full, challenge: issued.proof.text, anchors: opts.anchors, now: opts.now });
46
+ const existing = c.cosignatures ?? [];
47
+ if (existing.some((x) => x.certificate_sha256 === icp.certificate_sha256))
48
+ throw new KyaError("CONFLICT", "this e-CPF certificate already co-signed the credential");
49
+ const entry = { method: "icp-brasil-e-cpf", certificate_sha256: icp.certificate_sha256, issuer: icp.issuer, signed_at: opts.now.toISOString(), not_after: icp.not_after };
50
+ return {
51
+ credential: { ...c, cosignatures: [...existing, entry] },
52
+ proof: { ...issued.proof, cosignatures: [...(issued.proof.cosignatures ?? []), full] }
53
+ };
54
+ }
55
+ /**
56
+ * Whether a self-issued company credential expresses the company's will: the e-CNPJ proof verifies
57
+ * (verifySelfIssuedCredential) AND at least `minCosigners` (default 1) distinct CPFs, read from valid e-CPF
58
+ * co-signatures over the same text, are among `representatives(cnpj)` (the verifier's QSA list). Every co-signature
59
+ * in the proof must be recorded in the credential (by certificate hash) and vice versa. Never throws on a bad
60
+ * credential; the result carries no CPF. Revocation of the e-CPF certificates is the caller's (`revocation`).
61
+ */
62
+ export async function verifyCompanyWill(issued, opts) {
63
+ const refuse = (code, detail, cosigners = [], counted = 0) => ({ ok: false, code, detail, representatives_counted: counted, cosigners });
64
+ const min = opts?.minCosigners ?? 1;
65
+ if (!Number.isInteger(min) || min < 1)
66
+ throw bad("minCosigners must be an integer >= 1");
67
+ if (typeof opts?.representatives !== "function")
68
+ throw bad("representatives: (cnpj) => CPFs of the company's legal representatives is required");
69
+ const c = issued?.credential;
70
+ if (!c?.principal || isPersonPrincipal(c.principal))
71
+ return refuse("COMPANY_SIGNATURE_INVALID", "not a company credential");
72
+ const base = await verifySelfIssuedCredential({ credential: c, proof: issued.proof, anchors: opts.anchors, now: opts.now });
73
+ if (!base.ok)
74
+ return refuse("COMPANY_SIGNATURE_INVALID", base.checks.filter((x) => !x.ok).map((x) => `${x.rule}: ${x.detail}`).join("; "));
75
+ const cmsList = issued.proof.cosignatures ?? [];
76
+ const recorded = c.cosignatures ?? [];
77
+ if (!cmsList.length && !recorded.length)
78
+ return refuse("NO_COSIGNATURE", "the credential carries no co-signature by a legal representative (e-CPF)");
79
+ const cnpj = normalizeCnpj(c.principal.cnpj);
80
+ const reps = new Set((await opts.representatives(cnpj)).map((x) => String(x).replace(/\D/g, "")));
81
+ const cosigners = [];
82
+ const counted = new Set();
83
+ const seenCerts = new Set();
84
+ for (const cms of cmsList) {
85
+ try {
86
+ const icp = await verifyIcpPersonSignature({ cmsBase64: cms, challenge: issued.proof.text, anchors: opts.anchors, now: opts.now });
87
+ const revocation = { serial_number: icp.serial_number, issuer_certificate: icp.issuer_certificate, crl_urls: icp.crl_urls };
88
+ seenCerts.add(icp.certificate_sha256);
89
+ if (!recorded.some((r) => r.certificate_sha256 === icp.certificate_sha256)) {
90
+ cosigners.push({ certificate_sha256: icp.certificate_sha256, ok: false, representative: false, code: "COSIGNATURE_NOT_RECORDED", detail: "the credential does not record this co-signer's certificate", revocation });
91
+ continue;
92
+ }
93
+ const isRep = reps.has(icp.cpf);
94
+ if (isRep)
95
+ counted.add(icp.cpf);
96
+ cosigners.push({ certificate_sha256: icp.certificate_sha256, ok: isRep, representative: isRep, ...(isRep ? {} : { code: "COSIGNER_NOT_REPRESENTATIVE" }), detail: isRep ? "valid co-signature by a legal representative" : `the co-signer is not a legal representative of CNPJ ${cnpj}`, revocation });
97
+ }
98
+ catch (e) {
99
+ const k = e instanceof KyaError ? e.code : "CHALLENGE_FAILED";
100
+ const code = k === "CERTIFICATE_INVALID" ? "COSIGNER_CERTIFICATE_INVALID" : "COSIGNATURE_TEXT_MISMATCH";
101
+ cosigners.push({ certificate_sha256: null, ok: false, representative: false, code, detail: e.message, revocation: null });
102
+ }
103
+ }
104
+ for (const r of recorded) {
105
+ if (!seenCerts.has(r.certificate_sha256))
106
+ cosigners.push({ certificate_sha256: r.certificate_sha256, ok: false, representative: false, code: "COSIGNATURE_NOT_RECORDED", detail: "the credential records a co-signature the proof does not carry (or it did not verify)", revocation: null });
107
+ }
108
+ // A tampered or unmatched record is a refusal on its own: nothing about the cosignatures may be inconsistent.
109
+ const broken = cosigners.find((x) => x.code && x.code !== "COSIGNER_NOT_REPRESENTATIVE");
110
+ if (broken)
111
+ return refuse(broken.code, broken.detail, cosigners, counted.size);
112
+ if (counted.size >= min)
113
+ return { ok: true, detail: `${counted.size} legal representative(s) co-signed`, representatives_counted: counted.size, cosigners };
114
+ if (counted.size === 0)
115
+ return refuse("COSIGNER_NOT_REPRESENTATIVE", `no co-signer is a legal representative of CNPJ ${cnpj}`, cosigners, 0);
116
+ return refuse("INSUFFICIENT_COSIGNERS", `${counted.size} of ${min} required legal representatives co-signed`, cosigners, counted.size);
117
+ }
package/dist/index.d.ts CHANGED
@@ -14,3 +14,4 @@ export * from "./delegation.js";
14
14
  export * from "./freshness.js";
15
15
  export * from "./principal-proof.js";
16
16
  export * from "./issuance.js";
17
+ export * from "./cosign.js";
package/dist/index.js CHANGED
@@ -14,3 +14,4 @@ export * from "./delegation.js";
14
14
  export * from "./freshness.js";
15
15
  export * from "./principal-proof.js";
16
16
  export * from "./issuance.js";
17
+ export * from "./cosign.js";
@@ -3,6 +3,12 @@ import { type Credential } from "./types.js";
3
3
  export interface PrincipalProof {
4
4
  text: string;
5
5
  cms: string;
6
+ /**
7
+ * Co-signatures by legal representatives (SPEC section 21): CMS by e-CPF over the same `text`. Optional, additive.
8
+ * They carry the representatives' certificates (and so their CPF): presented to the verifier, never stored in the
9
+ * credential.
10
+ */
11
+ cosignatures?: string[];
6
12
  }
7
13
  export interface PrincipalProofCheck {
8
14
  rule: "signature" | "certificate" | "agent" | "principal" | "issuer" | "mandate";
@@ -79,7 +79,7 @@ export async function verifyPrincipalProof(p) {
79
79
  }
80
80
  /** Header value for a request: base64url of the JSON { text, cms }. */
81
81
  export function encodePrincipalProofHeader(proof) {
82
- return base64url.encode(new TextEncoder().encode(JSON.stringify({ text: proof.text, cms: proof.cms })));
82
+ return base64url.encode(new TextEncoder().encode(JSON.stringify(proof.cosignatures?.length ? { text: proof.text, cms: proof.cms, cosignatures: proof.cosignatures } : { text: proof.text, cms: proof.cms })));
83
83
  }
84
84
  /** Parses the header value; throws INVALID_INPUT when it is not a proof. */
85
85
  export function decodePrincipalProofHeader(value) {
@@ -93,5 +93,9 @@ export function decodePrincipalProofHeader(value) {
93
93
  const o = parsed;
94
94
  if (!o || typeof o.text !== "string" || typeof o.cms !== "string")
95
95
  throw new KyaError("INVALID_INPUT", "KYA-Principal-Proof must carry text and cms");
96
- return { text: o.text, cms: o.cms };
96
+ if (o.cosignatures === undefined)
97
+ return { text: o.text, cms: o.cms };
98
+ if (!Array.isArray(o.cosignatures) || !o.cosignatures.every((c) => typeof c === "string"))
99
+ throw new KyaError("INVALID_INPUT", "KYA-Principal-Proof cosignatures must be a list of base64 CMS");
100
+ return { text: o.text, cms: o.cms, cosignatures: o.cosignatures };
97
101
  }
package/dist/types.d.ts CHANGED
@@ -121,6 +121,19 @@ export interface Credential {
121
121
  verify_url: string;
122
122
  revocation_url: string;
123
123
  attestation?: Attestation;
124
+ /**
125
+ * Co-signatures of a self-issued company credential by legal representatives (SPEC section 21, additive): which
126
+ * e-CPF certificates co-signed the same text, by hash. No CPF, no name: the CMS travel in the proof.
127
+ */
128
+ cosignatures?: Cosignature[];
129
+ }
130
+ /** A legal representative's co-signature as the credential records it: no personal data. */
131
+ export interface Cosignature {
132
+ method: "icp-brasil-e-cpf";
133
+ certificate_sha256: string;
134
+ issuer: string;
135
+ signed_at: string;
136
+ not_after: string;
124
137
  }
125
138
  export declare class KyaError extends Error {
126
139
  code: "INVALID_INPUT" | "INVALID_CNPJ" | "NOT_FOUND" | "CHALLENGE_FAILED" | "CERTIFICATE_INVALID" | "CNPJ_MISMATCH" | "KYB_BLOCKED" | "KYB_UNAVAILABLE" | "CERTIFICATE_REVOKED" | "REVOCATION_UNAVAILABLE" | "REGISTRY_UNAVAILABLE" | "RATE_LIMITED" | "UPSTREAM_ERROR" | "CONFLICT";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kya-brasil/core",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
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, issuance by the principal itself (build the mandate, the exact bytes to sign, assemble and verify, no issuer service), 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",