@kya-brasil/core 0.17.0 → 0.18.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
@@ -8,6 +8,7 @@ systems, with no hosted service in the path: the same code the reference issuer
8
8
  - **Agent keys**: challenge text and signature checks for EVM wallets (EIP-191), Ed25519 (Web Bot Auth, RFC 9421) and ERC-8004 agents.
9
9
  - **Mandate**: schema, validation and deterministic evaluation of an intended action, including **payment instruments** for card networks, acquirers and issuers.
10
10
  - **Credential**: build, sign and verify (Ed25519 over canonical JSON), offline.
11
+ - **Issuance by the principal**: the e-CNPJ / e-CPF holder issues the credential itself, with no issuer service: build the mandate, get the exact bytes to sign, assemble, and verify (`buildMandate`, `mandateSigningInput`, `assembleCredential`, `buildDelegation`).
11
12
  - **CNPJ**: normalize, validate, format.
12
13
 
13
14
  Storage, network calls and keys stay yours: the library never fetches anything except an ERC-8004 lookup over
@@ -46,6 +47,80 @@ const verdict = checkAction(credential.mandate, {
46
47
  // verdict.allowed, verdict.checks (every rule evaluated, with its reason), verdict.not_evaluated (extensions)
47
48
  ```
48
49
 
50
+ ## Emissão pelo responsável (issue it yourself, no issuer service)
51
+
52
+ There is no native link between an ICP-Brasil certificate and an AI agent: the link is the KYA format, so the
53
+ library issues it as well as verifies it. **The issuer is the principal itself**, the holder of the e-CNPJ or e-CPF,
54
+ not a service in between: no account, no API, no one else sees the certificate or decides on the powers. In a
55
+ delegation, the parent agent is the issuer of its sub-agent's mandate. SPEC section 20, decision D-37.
56
+
57
+ Issuance is symmetric to verification and uses the same functions: the mandate is validated by the verifier's
58
+ schema, hashed with the same canonical JSON (SPEC section 7), and the text the certificate signs is the same
59
+ challenge text `verifyPrincipalProof` reads. Every risk field is required (no defaults: `[]` says "any",
60
+ `delegation: null` says "may not delegate"), and whatever verification would refuse is refused at issuance.
61
+
62
+ ```ts
63
+ import { buildMandate, mandateSigningInput, assembleCredential, verifySelfIssuedCredential, openPfx, encodePrincipalProofHeader } from "@kya-brasil/core";
64
+
65
+ // 1. The principal states the powers. Missing field = error.
66
+ const built = await buildMandate({
67
+ credential_id: crypto.randomUUID(),
68
+ agent: { kind: "evm", id: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" }, // the agent's public key
69
+ principal: { kind: "company", cnpj: "11.222.333/0001-81" }, // or { kind: "person" } (e-CPF)
70
+ mandate: {
71
+ actions: ["quote", "order", "pay"],
72
+ max_per_operation: { amount: 5000, currency: "BRL" },
73
+ max_per_day: { amount: 20000, currency: "BRL" },
74
+ currencies: ["BRL"],
75
+ counterparties: ["33000167000101", "*.fornecedor.com.br"],
76
+ payment_instruments: [],
77
+ valid_from: "2026-11-01T00:00:00Z",
78
+ valid_until: "2027-03-01T00:00:00Z",
79
+ delegation: { max_depth: 1, max_ttl_seconds: 3600 } // or null
80
+ },
81
+ ttl_seconds: 30 * 86400,
82
+ now: new Date()
83
+ });
84
+
85
+ // 2. The exact bytes the certificate signs (text, UTF-8 bytes and their SHA-256).
86
+ const { text } = await mandateSigningInput(built);
87
+
88
+ // 3. Sign with any ICP-Brasil signer that produces a CMS (detached or attached, SHA-256, RSA PKCS#1 v1.5):
89
+ // an A1 file here; a token, HSM or cloud certificate through ecnpjSigner / buildDetachedCms / cloudSigner.
90
+ const signer = await openPfx(pfxBytes, password);
91
+ const cms = await signer.sign(text);
92
+
93
+ // 4. Assemble: checks the signature, the chain to the anchors and the CNPJ as a verifier will, and refuses otherwise.
94
+ const { credential, proof } = await assembleCredential(built, cms, [], { anchors: icpBrasilAnchors, now: new Date() });
95
+ // Check the certificate's CRL now (checkRevocation), then hand { credential, proof } to the agent.
96
+
97
+ // 5. The agent presents both; any verifier checks them against ICP-Brasil alone.
98
+ const header = encodePrincipalProofHeader(proof); // KYA-Principal-Proof
99
+ const r = await verifySelfIssuedCredential({ credential, proof, anchors: icpBrasilAnchors, now: new Date() });
100
+ // r.ok; r.checks: signature, certificate, agent, principal, issuer, mandate, credential, validity
101
+ ```
102
+
103
+ A self-issued credential has `issuer: "urn:kya-brasil:self"` and no issuer attestation: the principal's signature is
104
+ the proof, and its status is the certificate's (CRL) and the credential's own expiry, which never passes the
105
+ mandate's `valid_until` nor the certificate's end of validity. To revoke before that, the principal revokes the
106
+ certificate or issues mandates with short lifetimes; there is no status service to ask. The certificate signer
107
+ stays outside the core: an A3 token (PKCS#11 and PIN) is `openToken` in `@kya-brasil/agent-tools`.
108
+
109
+ Delegation, issued by the parent agent with its own key:
110
+
111
+ ```ts
112
+ import { buildDelegation, assembleDelegation, encodeDelegationHeader } from "@kya-brasil/core";
113
+
114
+ const prepared = await buildDelegation({ credential }, {
115
+ delegate: { kind: "evm", id: subAgentAddress },
116
+ mandate: { ...narrowerTerms, delegation: null }, // all fields required; refused if broader than the parent's
117
+ ttl_seconds: 600,
118
+ now: new Date()
119
+ });
120
+ const chain = await assembleDelegation({ credential }, prepared, await agentAccount.signMessage({ message: prepared.text }), new Date());
121
+ // KYA-Delegation: encodeDelegationHeader(chain); verifiers use verifyDelegationChain, unchanged.
122
+ ```
123
+
49
124
  ## Without trusting the issuer
50
125
 
51
126
  Since D-33 the e-CNPJ / e-CPF signs the agent and the mandate's hash, and the principal keeps the signed text and the
package/dist/index.d.ts CHANGED
@@ -13,3 +13,4 @@ export * from "./person.js";
13
13
  export * from "./delegation.js";
14
14
  export * from "./freshness.js";
15
15
  export * from "./principal-proof.js";
16
+ export * from "./issuance.js";
package/dist/index.js CHANGED
@@ -13,3 +13,4 @@ export * from "./person.js";
13
13
  export * from "./delegation.js";
14
14
  export * from "./freshness.js";
15
15
  export * from "./principal-proof.js";
16
+ export * from "./issuance.js";
@@ -0,0 +1,182 @@
1
+ import { type Delegation, type DelegationAnchor } from "./delegation.js";
2
+ import { type PrincipalProof, type PrincipalProofCheck } from "./principal-proof.js";
3
+ import { type AgentKey, type Credential, type DelegationPolicy, type Mandate, type PaymentInstrument } from "./types.js";
4
+ /**
5
+ * The `issuer` of a credential issued by its own principal. It goes in the signed text (`issuer:` line) and in the
6
+ * credential, so a verifier tells at once that no service vouches for the credential's status: only the
7
+ * certificate (signature, chain, CRL) and the credential's own expiry do.
8
+ */
9
+ export declare const SELF_ISSUER = "urn:kya-brasil:self";
10
+ /** Fields every issued mandate must state: no default stands in for a risk decision (an empty list is explicit: any). */
11
+ export declare const MANDATE_REQUIRED_FIELDS: readonly ["actions", "max_per_operation", "max_per_day", "currencies", "counterparties", "payment_instruments", "valid_from", "valid_until", "delegation"];
12
+ /** A mandate to issue. Every reserved field is required; `delegation: null` says, explicitly, "may not delegate". */
13
+ export interface MandateTerms {
14
+ actions: string[];
15
+ max_per_operation: {
16
+ amount: number;
17
+ currency: string;
18
+ };
19
+ max_per_day: {
20
+ amount: number;
21
+ currency: string;
22
+ };
23
+ currencies: string[];
24
+ /** CNPJs, domains (`*.domain` for subdomains) or wallet addresses; `[]` = any counterparty, stated explicitly. */
25
+ counterparties: string[];
26
+ /** `[]` = any instrument, stated explicitly. */
27
+ payment_instruments: PaymentInstrument[];
28
+ valid_from: string;
29
+ valid_until: string;
30
+ delegation: DelegationPolicy | null;
31
+ /** Extensions are carried in the credential and never evaluated (SPEC section 5). */
32
+ [extension: string]: unknown;
33
+ }
34
+ export interface MandateInput {
35
+ /** Identifier of the credential, chosen by the principal (a UUID is a good choice). Goes into the signed text. */
36
+ credential_id: string;
37
+ /** The agent's public key: evm address, Ed25519 (web-bot-auth) or ERC-8004 identity. */
38
+ agent: {
39
+ kind: unknown;
40
+ id: unknown;
41
+ };
42
+ /** Who answers for the agent. A person gives nothing personal: the e-CPF that signs identifies them. */
43
+ principal: {
44
+ kind: "company";
45
+ cnpj: string;
46
+ } | {
47
+ kind: "person";
48
+ };
49
+ mandate: MandateTerms;
50
+ /** Credential lifetime in seconds (D-35). Capped by `valid_until` here and by the certificate's notAfter at assembly. */
51
+ ttl_seconds: number;
52
+ /** The issuance time. */
53
+ now: Date;
54
+ }
55
+ /** A built mandate: everything the certificate will sign, in canonical form. Do not edit it after building. */
56
+ export interface BuiltMandate {
57
+ version: 1;
58
+ credential_id: string;
59
+ issuer: typeof SELF_ISSUER;
60
+ agent: AgentKey;
61
+ principal: {
62
+ kind: "company";
63
+ cnpj: string;
64
+ } | {
65
+ kind: "person";
66
+ };
67
+ mandate: Mandate;
68
+ /** SHA-256 of the canonical JSON of `mandate` (mandateSha256): what the signed text commits to. */
69
+ mandate_sha256: string;
70
+ issued_at: string;
71
+ /** Upper bound of the credential's expiry, signed in the text's `expires:` line. */
72
+ expires_at: string;
73
+ }
74
+ /** What the certificate signs: the text, its exact bytes (UTF-8, no trailing newline) and their SHA-256. */
75
+ export interface SigningInput {
76
+ text: string;
77
+ bytes: Uint8Array;
78
+ /** Lowercase hex SHA-256 of `bytes`: the CMS messageDigest attribute, for signers that take a precomputed hash. */
79
+ sha256: string;
80
+ }
81
+ /** What the agent holds and presents: the credential and the principal's proof (KYA-Principal-Proof). */
82
+ export interface IssuedCredential {
83
+ credential: Credential;
84
+ proof: PrincipalProof;
85
+ }
86
+ /**
87
+ * Checks that a mandate states every risk field (MANDATE_REQUIRED_FIELDS), validates it with validateMandate (the
88
+ * verifier's own schema) and returns it normalized, with `delegation: null` removed (absent = may not delegate).
89
+ */
90
+ export declare function requireMandateTerms(input: unknown, where?: string): Mandate;
91
+ /**
92
+ * Builds the mandate a principal issues to its agent: validated as the verifier validates it, every risk field
93
+ * stated, and bound to the agent, the principal, the credential id and the issuance window. Throws INVALID_INPUT on
94
+ * anything verification would refuse (and on any missing risk field).
95
+ */
96
+ export declare function buildMandate(input: MandateInput): Promise<BuiltMandate>;
97
+ /**
98
+ * The exact bytes the principal's ICP-Brasil certificate must sign: the KYA challenge text (company or person) with
99
+ * issuer SELF_ISSUER, the credential id, the agent, the principal, the SHA-256 of the canonical mandate and the
100
+ * expiry bound. The same text verifyPrincipalProof reads, built by the same function.
101
+ */
102
+ export declare function mandateSigningInput(b: BuiltMandate): Promise<SigningInput>;
103
+ /**
104
+ * The CMS with the given certificates added when it does not carry them already (signers that return only the
105
+ * signature, or only the leaf). Certificates are outside what the signature covers, so adding them changes nothing
106
+ * that was signed.
107
+ */
108
+ export declare function withCertificates(cmsBase64: string, certChain: (Uint8Array | string)[]): string;
109
+ /**
110
+ * Assembles the credential from the built mandate, the detached (or attached) CMS signature over
111
+ * mandateSigningInput(b).text and the certificate chain (signer first; added to the CMS when missing). Verifies the
112
+ * signature, the chain up to `anchors`, the CNPJ or the e-CPF, as a verifier will, and refuses (throws KyaError)
113
+ * anything the verification would refuse. The certificate's revocation (CRL) stays with the caller, as in
114
+ * verification: check it before handing the credential to the agent.
115
+ *
116
+ * Returns the credential in the /v1 format (issuer SELF_ISSUER, no issuer attestation: the principal's signature
117
+ * is the proof) and the proof the agent presents with it (KYA-Principal-Proof, encodePrincipalProofHeader).
118
+ */
119
+ export declare function assembleCredential(b: BuiltMandate, signature: string, certChain: (Uint8Array | string)[], opts: {
120
+ anchors: string[];
121
+ now: Date;
122
+ }): Promise<IssuedCredential>;
123
+ export interface SelfIssuedCheck {
124
+ rule: PrincipalProofCheck["rule"] | "credential" | "validity";
125
+ ok: boolean;
126
+ detail: string;
127
+ }
128
+ export interface SelfIssuedResult {
129
+ ok: boolean;
130
+ checks: SelfIssuedCheck[];
131
+ /** For the caller's revocation check of the signing certificate (checkRevocation); null when the signature did not verify. */
132
+ revocation: {
133
+ serial_number: string;
134
+ issuer_certificate: string | null;
135
+ crl_urls: string[];
136
+ } | null;
137
+ }
138
+ /**
139
+ * Verifies a credential issued by its principal, offline: every check of verifyPrincipalProof (signature and chain
140
+ * to the anchors, certificate, agent, principal, issuer, mandate hash), plus what binds the credential's own fields
141
+ * to the signed text: issuer SELF_ISSUER, the text's `challenge:` line is the credential id, the credential does not
142
+ * outlive the text's `expires:` line, the mandate passes the schema, and the credential is in force at `now`.
143
+ * Never throws on a bad credential. The certificate's revocation (CRL) is the caller's, with `revocation`.
144
+ */
145
+ export declare function verifySelfIssuedCredential(p: {
146
+ credential: Credential;
147
+ proof: PrincipalProof;
148
+ anchors: string[];
149
+ now: Date;
150
+ }): Promise<SelfIssuedResult>;
151
+ /** Who issues the next link: an issued credential (its agent delegates), or that credential and the chain so far. */
152
+ export interface DelegationParent {
153
+ credential: DelegationAnchor & Partial<Pick<Credential, "status">>;
154
+ chain?: Delegation[];
155
+ }
156
+ /**
157
+ * Builds the mandate a (sub-)agent issues to the next key: the parent's agent (or the last delegate of `chain`) is
158
+ * the issuer. Every risk field of the child mandate is required (as in buildMandate); the delegation is refused when
159
+ * verification would refuse it: the parent may not delegate, the depth or lifetime exceeds its policy, the child
160
+ * mandate is broader than the parent's (verifyDelegationChain's rules, through prepareDelegation), or the parent
161
+ * credential is not in force at `now`. Returns the unsigned link and the text the delegator signs with its key.
162
+ */
163
+ export declare function buildDelegation(parent: DelegationParent, child: {
164
+ delegate: {
165
+ kind: unknown;
166
+ id: unknown;
167
+ };
168
+ mandate: MandateTerms;
169
+ ttl_seconds: number;
170
+ now: Date;
171
+ }): Promise<{
172
+ delegation: Omit<Delegation, "signature">;
173
+ text: string;
174
+ }>;
175
+ /**
176
+ * Joins the delegator's signature (EIP-191 for evm, raw Ed25519 base64url for web-bot-auth) to a link from
177
+ * buildDelegation and returns the whole chain, after checking it with verifyDelegationChain at `now`: a wrong key or
178
+ * a changed link is refused here, not at the verifier.
179
+ */
180
+ export declare function assembleDelegation(parent: DelegationParent, prepared: {
181
+ delegation: Omit<Delegation, "signature">;
182
+ }, signature: string, now: Date): Promise<Delegation[]>;
@@ -0,0 +1,333 @@
1
+ import * as pkijs from "pkijs";
2
+ import { canonicalize } from "./attestation.js";
3
+ import { challengeText, normalizeAgentKey } from "./challenge.js";
4
+ import { isValidCnpj, normalizeCnpj } from "./cnpj.js";
5
+ import { buildCredential, buildPersonCredential, effectiveStatus, mandateSha256 } from "./credential.js";
6
+ import { prepareDelegation, verifyDelegationChain } from "./delegation.js";
7
+ import { verifyIcpPersonSignature, verifyIcpSignature } from "./icp-brasil.js";
8
+ import { validateMandate } from "./mandate.js";
9
+ import { personChallengeText } from "./person.js";
10
+ import { verifyPrincipalProof } from "./principal-proof.js";
11
+ import { KyaError, isPersonPrincipal } from "./types.js";
12
+ /**
13
+ * The `issuer` of a credential issued by its own principal. It goes in the signed text (`issuer:` line) and in the
14
+ * credential, so a verifier tells at once that no service vouches for the credential's status: only the
15
+ * certificate (signature, chain, CRL) and the credential's own expiry do.
16
+ */
17
+ export const SELF_ISSUER = "urn:kya-brasil:self";
18
+ /** Fields every issued mandate must state: no default stands in for a risk decision (an empty list is explicit: any). */
19
+ export const MANDATE_REQUIRED_FIELDS = ["actions", "max_per_operation", "max_per_day", "currencies", "counterparties", "payment_instruments", "valid_from", "valid_until", "delegation"];
20
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
21
+ const bad = (why) => new KyaError("INVALID_INPUT", why);
22
+ const hex = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
23
+ function requireDate(now, what = "now") {
24
+ if (!(now instanceof Date) || Number.isNaN(now.getTime()))
25
+ throw bad(`${what} must be a valid Date (the clock is the caller's)`);
26
+ return now;
27
+ }
28
+ /**
29
+ * Checks that a mandate states every risk field (MANDATE_REQUIRED_FIELDS), validates it with validateMandate (the
30
+ * verifier's own schema) and returns it normalized, with `delegation: null` removed (absent = may not delegate).
31
+ */
32
+ export function requireMandateTerms(input, where = "mandate") {
33
+ if (!input || typeof input !== "object" || Array.isArray(input))
34
+ throw bad(`${where} must be a JSON object`);
35
+ const terms = input;
36
+ const missing = MANDATE_REQUIRED_FIELDS.filter((k) => terms[k] === undefined);
37
+ if (missing.length)
38
+ throw bad(`${where}: missing ${missing.join(", ")} (no default stands in for a risk decision; use [] for "any" and delegation: null for "may not delegate")`);
39
+ if (!Array.isArray(terms.actions) || terms.actions.length === 0)
40
+ throw bad(`${where}.actions: list at least one action`);
41
+ const { delegation, ...rest } = terms;
42
+ return validateMandate(delegation === null ? rest : { ...rest, delegation });
43
+ }
44
+ /**
45
+ * Builds the mandate a principal issues to its agent: validated as the verifier validates it, every risk field
46
+ * stated, and bound to the agent, the principal, the credential id and the issuance window. Throws INVALID_INPUT on
47
+ * anything verification would refuse (and on any missing risk field).
48
+ */
49
+ export async function buildMandate(input) {
50
+ if (!input || typeof input !== "object")
51
+ throw bad("input must be an object");
52
+ const now = requireDate(input.now);
53
+ if (typeof input.credential_id !== "string" || !ID.test(input.credential_id))
54
+ throw bad("credential_id: 1 to 128 characters, letters, digits and . _ : - (it goes into the signed text)");
55
+ if (!input.agent || typeof input.agent !== "object")
56
+ throw bad("agent: { kind, id } is required");
57
+ const agent = normalizeAgentKey(input.agent);
58
+ const p = input.principal;
59
+ let principal;
60
+ if (p?.kind === "company") {
61
+ if (typeof p.cnpj !== "string" || !isValidCnpj(p.cnpj))
62
+ throw bad("principal.cnpj must be a valid CNPJ");
63
+ principal = { kind: "company", cnpj: normalizeCnpj(p.cnpj) };
64
+ }
65
+ else if (p?.kind === "person")
66
+ principal = { kind: "person" };
67
+ else
68
+ throw bad('principal.kind must be "company" (with cnpj) or "person"');
69
+ const mandate = requireMandateTerms(input.mandate);
70
+ const ttl = input.ttl_seconds;
71
+ if (typeof ttl !== "number" || !Number.isFinite(ttl) || ttl <= 0)
72
+ throw bad("ttl_seconds must be a positive number of seconds (the credential lifetime is the caller's decision)");
73
+ const until = Date.parse(mandate.valid_until);
74
+ if (until <= now.getTime())
75
+ throw bad(`mandate.valid_until: ${mandate.valid_until} is not after now`);
76
+ const expires = Math.min(now.getTime() + ttl * 1000, until);
77
+ return {
78
+ version: 1,
79
+ credential_id: input.credential_id,
80
+ issuer: SELF_ISSUER,
81
+ agent,
82
+ principal,
83
+ mandate,
84
+ mandate_sha256: await mandateSha256(mandate),
85
+ issued_at: now.toISOString(),
86
+ expires_at: new Date(expires).toISOString()
87
+ };
88
+ }
89
+ /** Re-checks a built mandate (it may have travelled between building and signing). */
90
+ async function checkBuilt(b) {
91
+ if (!b || b.version !== 1 || b.issuer !== SELF_ISSUER || typeof b.credential_id !== "string" || !ID.test(b.credential_id))
92
+ throw bad("not a mandate built by buildMandate");
93
+ const agent = normalizeAgentKey(b.agent ?? {});
94
+ if (agent.id !== b.agent.id)
95
+ throw bad("agent is not in normalized form");
96
+ if (b.principal?.kind === "company" && normalizeCnpj(b.principal.cnpj) !== b.principal.cnpj)
97
+ throw bad("principal.cnpj is not in normalized form");
98
+ if (b.principal?.kind !== "company" && b.principal?.kind !== "person")
99
+ throw bad("principal.kind must be company or person");
100
+ const m = validateMandate(b.mandate);
101
+ if (canonicalize(m) !== canonicalize(b.mandate))
102
+ throw bad("mandate is not in normalized form");
103
+ if ((await mandateSha256(b.mandate)) !== b.mandate_sha256)
104
+ throw bad("mandate_sha256 does not match the mandate: it was changed after buildMandate");
105
+ const issued = Date.parse(b.issued_at);
106
+ const expires = Date.parse(b.expires_at);
107
+ if (!Number.isFinite(issued) || !Number.isFinite(expires) || expires <= issued)
108
+ throw bad("bad issued_at / expires_at");
109
+ }
110
+ /**
111
+ * The exact bytes the principal's ICP-Brasil certificate must sign: the KYA challenge text (company or person) with
112
+ * issuer SELF_ISSUER, the credential id, the agent, the principal, the SHA-256 of the canonical mandate and the
113
+ * expiry bound. The same text verifyPrincipalProof reads, built by the same function.
114
+ */
115
+ export async function mandateSigningInput(b) {
116
+ await checkBuilt(b);
117
+ const base = { issuer: b.issuer, challenge_id: b.credential_id, agent: b.agent, mandate_sha256: b.mandate_sha256, expires_at: b.expires_at };
118
+ const text = b.principal.kind === "company" ? challengeText({ ...base, cnpj: b.principal.cnpj }) : personChallengeText(base);
119
+ const bytes = new TextEncoder().encode(text);
120
+ return { text, bytes, sha256: hex(await crypto.subtle.digest("SHA-256", bytes)) };
121
+ }
122
+ const unb64 = (s) => Uint8Array.from(atob(s.replace(/-----[^-]+-----/g, "").replace(/\s+/g, "")), (c) => c.charCodeAt(0));
123
+ const toArrayBuffer = (u) => u.buffer.slice(u.byteOffset, u.byteOffset + u.byteLength);
124
+ const derOf = (c) => (typeof c === "string" ? unb64(c) : c);
125
+ /**
126
+ * The CMS with the given certificates added when it does not carry them already (signers that return only the
127
+ * signature, or only the leaf). Certificates are outside what the signature covers, so adding them changes nothing
128
+ * that was signed.
129
+ */
130
+ export function withCertificates(cmsBase64, certChain) {
131
+ let ci;
132
+ let signed;
133
+ try {
134
+ ci = pkijs.ContentInfo.fromBER(toArrayBuffer(unb64(cmsBase64)));
135
+ signed = new pkijs.SignedData({ schema: ci.content });
136
+ }
137
+ catch (e) {
138
+ throw new KyaError("CHALLENGE_FAILED", `signature is not a CMS SignedData: ${e.message}`);
139
+ }
140
+ const have = (signed.certificates ?? []).filter((c) => c instanceof pkijs.Certificate);
141
+ const key = (c) => hex(c.toSchema().toBER(false));
142
+ const seen = new Set(have.map(key));
143
+ let added = 0;
144
+ for (const raw of certChain) {
145
+ let cert;
146
+ try {
147
+ cert = pkijs.Certificate.fromBER(toArrayBuffer(derOf(raw)));
148
+ }
149
+ catch (e) {
150
+ throw bad(`certChain: not an X.509 certificate (DER or base64/PEM): ${e.message}`);
151
+ }
152
+ if (seen.has(key(cert)))
153
+ continue;
154
+ seen.add(key(cert));
155
+ have.push(cert);
156
+ added++;
157
+ }
158
+ if (!added)
159
+ return cmsBase64.replace(/\s+/g, "");
160
+ signed.certificates = have;
161
+ const out = new pkijs.ContentInfo({ contentType: ci.contentType, content: signed.toSchema(true) });
162
+ const der = new Uint8Array(out.toSchema().toBER(false));
163
+ let bin = "";
164
+ for (const b of der)
165
+ bin += String.fromCharCode(b);
166
+ return btoa(bin);
167
+ }
168
+ /** "ACME LTDA" from "CN=ACME LTDA:11222333000181, O=ICP-Brasil, C=BR" (ICP-Brasil e-CNPJ convention), else the CN. */
169
+ function legalNameOf(subject) {
170
+ const cn = /(?:^|,\s*)CN=([^,]+)/.exec(subject)?.[1]?.trim() ?? "";
171
+ return cn.replace(/:\d{14}\s*$/, "").trim() || cn;
172
+ }
173
+ /**
174
+ * Assembles the credential from the built mandate, the detached (or attached) CMS signature over
175
+ * mandateSigningInput(b).text and the certificate chain (signer first; added to the CMS when missing). Verifies the
176
+ * signature, the chain up to `anchors`, the CNPJ or the e-CPF, as a verifier will, and refuses (throws KyaError)
177
+ * anything the verification would refuse. The certificate's revocation (CRL) stays with the caller, as in
178
+ * verification: check it before handing the credential to the agent.
179
+ *
180
+ * Returns the credential in the /v1 format (issuer SELF_ISSUER, no issuer attestation: the principal's signature
181
+ * is the proof) and the proof the agent presents with it (KYA-Principal-Proof, encodePrincipalProofHeader).
182
+ */
183
+ export async function assembleCredential(b, signature, certChain, opts) {
184
+ const { text } = await mandateSigningInput(b);
185
+ const now = requireDate(opts?.now, "opts.now");
186
+ if (!Array.isArray(opts.anchors) || !opts.anchors.length)
187
+ throw bad("opts.anchors: the ICP-Brasil trust anchors are required (the same a verifier uses)");
188
+ if (typeof signature !== "string" || !signature.trim())
189
+ throw bad("signature: the CMS (base64 DER) is required");
190
+ if (!Array.isArray(certChain))
191
+ throw bad("certChain must be an array (signer first); [] when the CMS already carries it");
192
+ const cms = withCertificates(signature, certChain);
193
+ const issuedAt = new Date(b.issued_at);
194
+ const ttl_seconds = (Date.parse(b.expires_at) - issuedAt.getTime()) / 1000;
195
+ let credential;
196
+ if (b.principal.kind === "company") {
197
+ const icp = await verifyIcpSignature({ cmsBase64: cms, challenge: text, expectedCnpj: b.principal.cnpj, anchors: opts.anchors, now });
198
+ credential = buildCredential({
199
+ credential_id: b.credential_id,
200
+ agent: b.agent,
201
+ cnpj: icp.cnpj,
202
+ legal_name: legalNameOf(icp.certificate_subject),
203
+ // Pure: nobody asked the Receita Federal. A verifier that needs it checks the CNPJ with its own source (D-24).
204
+ registration_status: "NOT_CHECKED",
205
+ certificate: { sha256: icp.certificate_sha256, subject: icp.certificate_subject, issuer: icp.issuer, signed_at: now.toISOString(), not_after: icp.not_after },
206
+ mandate: b.mandate,
207
+ mandate_sha256: b.mandate_sha256,
208
+ ttl_seconds,
209
+ issuer: SELF_ISSUER,
210
+ now: issuedAt
211
+ });
212
+ }
213
+ else {
214
+ const icp = await verifyIcpPersonSignature({ cmsBase64: cms, challenge: text, anchors: opts.anchors, now });
215
+ credential = buildPersonCredential({
216
+ credential_id: b.credential_id,
217
+ agent: b.agent,
218
+ certificate: { sha256: icp.certificate_sha256, issuer: icp.issuer, signed_at: now.toISOString(), not_after: icp.not_after },
219
+ mandate: b.mandate,
220
+ mandate_sha256: b.mandate_sha256,
221
+ ttl_seconds,
222
+ issuer: SELF_ISSUER,
223
+ now: issuedAt
224
+ });
225
+ }
226
+ // No status service behind a self-issued credential: both URLs name the issuer, never a host to call.
227
+ credential = { ...credential, verify_url: SELF_ISSUER, revocation_url: SELF_ISSUER };
228
+ const issued = { credential, proof: { text, cms } };
229
+ const check = await verifySelfIssuedCredential({ ...issued, anchors: opts.anchors, now });
230
+ if (!check.ok)
231
+ throw new KyaError("CHALLENGE_FAILED", `the assembled credential would not verify: ${check.checks.filter((c) => !c.ok).map((c) => `${c.rule}: ${c.detail}`).join("; ")}`);
232
+ return issued;
233
+ }
234
+ const lineOf = (text, key) => {
235
+ const lines = text.split("\n").filter((l) => l.startsWith(`${key}: `));
236
+ return lines.length === 1 ? lines[0].slice(key.length + 2) : null;
237
+ };
238
+ /**
239
+ * Verifies a credential issued by its principal, offline: every check of verifyPrincipalProof (signature and chain
240
+ * to the anchors, certificate, agent, principal, issuer, mandate hash), plus what binds the credential's own fields
241
+ * to the signed text: issuer SELF_ISSUER, the text's `challenge:` line is the credential id, the credential does not
242
+ * outlive the text's `expires:` line, the mandate passes the schema, and the credential is in force at `now`.
243
+ * Never throws on a bad credential. The certificate's revocation (CRL) is the caller's, with `revocation`.
244
+ */
245
+ export async function verifySelfIssuedCredential(p) {
246
+ const checks = [];
247
+ const c = p.credential;
248
+ if (!c || typeof c !== "object" || !c.agent || !c.principal || !c.authorized_by || !c.mandate) {
249
+ return { ok: false, checks: [{ rule: "credential", ok: false, detail: "not a credential" }], revocation: null };
250
+ }
251
+ const base = await verifyPrincipalProof({ credential: c, proof: p.proof, anchors: p.anchors, now: p.now });
252
+ checks.push(...base.checks);
253
+ const text = typeof p.proof?.text === "string" ? p.proof.text : "";
254
+ const problems = [];
255
+ if (c.issuer !== SELF_ISSUER)
256
+ problems.push(`issuer is ${c.issuer}, not ${SELF_ISSUER}`);
257
+ if (c.version !== 1)
258
+ problems.push("version is not 1");
259
+ if (lineOf(text, "challenge") !== c.credential_id)
260
+ problems.push("the signed text does not name this credential id");
261
+ const method = isPersonPrincipal(c.principal) ? "icp-brasil-e-cpf" : "icp-brasil-e-cnpj";
262
+ if (c.authorized_by.method !== method)
263
+ problems.push(`authorized_by.method must be ${method}`);
264
+ try {
265
+ if (canonicalize(validateMandate(c.mandate)) !== canonicalize(c.mandate))
266
+ problems.push("mandate is not in normalized form");
267
+ }
268
+ catch (e) {
269
+ problems.push(e.message);
270
+ }
271
+ checks.push({ rule: "credential", ok: problems.length === 0, detail: problems.length ? problems.join("; ") : "self-issued credential bound to the signed text" });
272
+ const signedBound = Date.parse(lineOf(text, "expires") ?? "");
273
+ const expires = Date.parse(c.expires_at);
274
+ const issued = Date.parse(c.issued_at);
275
+ const now = p.now instanceof Date ? p.now.getTime() : Number.NaN;
276
+ const validity = [];
277
+ if (!Number.isFinite(signedBound))
278
+ validity.push("the signed text carries no expiry");
279
+ else if (!Number.isFinite(expires) || expires > signedBound)
280
+ validity.push("the credential outlives the expiry the principal signed");
281
+ if (!Number.isFinite(issued) || !Number.isFinite(expires) || issued >= expires)
282
+ validity.push("bad issued_at / expires_at");
283
+ else if (!Number.isFinite(now))
284
+ validity.push("the verifier's clock is not a valid date");
285
+ else if (now < issued)
286
+ validity.push(`not yet valid (issued ${c.issued_at})`);
287
+ else if (effectiveStatus(c, new Date(now)) !== "active")
288
+ validity.push(`credential is ${effectiveStatus(c, new Date(now))}`);
289
+ checks.push({ rule: "validity", ok: validity.length === 0, detail: validity.length ? validity.join("; ") : `in force until ${c.expires_at}` });
290
+ return { ok: checks.every((x) => x.ok), checks, revocation: base.revocation };
291
+ }
292
+ /**
293
+ * Builds the mandate a (sub-)agent issues to the next key: the parent's agent (or the last delegate of `chain`) is
294
+ * the issuer. Every risk field of the child mandate is required (as in buildMandate); the delegation is refused when
295
+ * verification would refuse it: the parent may not delegate, the depth or lifetime exceeds its policy, the child
296
+ * mandate is broader than the parent's (verifyDelegationChain's rules, through prepareDelegation), or the parent
297
+ * credential is not in force at `now`. Returns the unsigned link and the text the delegator signs with its key.
298
+ */
299
+ export async function buildDelegation(parent, child) {
300
+ if (!parent?.credential)
301
+ throw bad("parent.credential is required");
302
+ if (!child || typeof child !== "object")
303
+ throw bad("child must be an object");
304
+ const now = requireDate(child.now);
305
+ const credential = parent.credential;
306
+ if (credential.status !== undefined && credential.status !== "active")
307
+ throw bad(`the parent credential is ${credential.status}`);
308
+ const end = Date.parse(credential.expires_at);
309
+ if (!Number.isFinite(end) || end <= now.getTime())
310
+ throw bad("the parent credential is not in force at now");
311
+ for (const link of parent.chain ?? [])
312
+ if (Date.parse(link.expires_at) <= now.getTime())
313
+ throw bad("a link of the parent chain has expired");
314
+ const mandate = requireMandateTerms(child.mandate, "child.mandate");
315
+ if (typeof child.ttl_seconds !== "number")
316
+ throw bad("child.ttl_seconds is required (the delegation lifetime is the delegator's decision)");
317
+ return prepareDelegation({ credential, chain: parent.chain, delegate: child.delegate, mandate, ttlSeconds: child.ttl_seconds, now });
318
+ }
319
+ /**
320
+ * Joins the delegator's signature (EIP-191 for evm, raw Ed25519 base64url for web-bot-auth) to a link from
321
+ * buildDelegation and returns the whole chain, after checking it with verifyDelegationChain at `now`: a wrong key or
322
+ * a changed link is refused here, not at the verifier.
323
+ */
324
+ export async function assembleDelegation(parent, prepared, signature, now) {
325
+ requireDate(now);
326
+ if (typeof signature !== "string" || !signature)
327
+ throw bad("signature is required");
328
+ const chain = [...(parent.chain ?? []), { ...prepared.delegation, signature }];
329
+ const r = await verifyDelegationChain({ credential: parent.credential, chain, now });
330
+ if (!r.ok)
331
+ throw bad(`the delegation would not verify: ${r.error}: ${r.message}`);
332
+ return chain;
333
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kya-brasil/core",
3
- "version": "0.17.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.",
3
+ "version": "0.18.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, 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",
7
7
  "exports": {
@@ -57,7 +57,7 @@
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
+ "node-forge": "1.4.1",
61
61
  "pkijs": "^3.4.1",
62
62
  "reflect-metadata": "^0.2.2",
63
63
  "viem": "^2.57.5"