@kya-brasil/core 0.0.0-stage → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Marcel Oliveira
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,108 @@
1
- # Temporary Holding Version
1
+ # @kya-brasil/core
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ KYA Brasil as a library. Verify, or issue, know-your-agent credentials for Brazilian companies inside your own
4
+ systems, with no hosted service in the path: the same code the reference issuer runs, as pure functions.
5
+
6
+ - **e-CNPJ (ICP-Brasil)**: CMS/PKCS#7 signature validation, chain up to the ICP-Brasil roots, CNPJ read from the certificate.
7
+ - **Revocation**: CRL verification against the issuing AC and the effect on a credential (revoke, suspend, nothing).
8
+ - **Agent keys**: challenge text and signature checks for EVM wallets (EIP-191), Ed25519 (Web Bot Auth, RFC 9421) and ERC-8004 agents.
9
+ - **Mandate**: schema, validation and deterministic evaluation of an intended action, including **payment instruments** for card networks, acquirers and issuers.
10
+ - **Credential**: build, sign and verify (Ed25519 over canonical JSON), offline.
11
+ - **CNPJ**: normalize, validate, format.
12
+
13
+ Storage, network calls and keys stay yours: the library never fetches anything except an ERC-8004 lookup over
14
+ the RPC transport you give it. Spec: [docs/SPEC.md](https://github.com/celfons/kya-brasil/blob/main/docs/SPEC.md).
15
+
16
+ ```sh
17
+ npm install @kya-brasil/core
18
+ ```
19
+
20
+ ESM, Node 18+, Workers, Deno and browsers (Web Crypto).
21
+
22
+ ## Verify a credential and an action, offline
23
+
24
+ A credential from any KYA issuer (the hosted one, yours, a partner's) is checked with that issuer's public key,
25
+ published at `<issuer>/.well-known/kya.json`.
26
+
27
+ ```ts
28
+ import { verifyCredentialSignature, effectiveStatus, checkAction, hashPaymentRef } from "@kya-brasil/core";
29
+
30
+ const sig = await verifyCredentialSignature(credential, issuerPublicJwk); // { valid, key_id, signed_at }
31
+ if (!sig.valid || effectiveStatus(credential) !== "active") deny();
32
+ // For live status (revoked or suspended after issuance) read credential.verify_url, or accept that risk until expires_at.
33
+
34
+ const verdict = checkAction(credential.mandate, {
35
+ action: "buy",
36
+ amount: 4200,
37
+ currency: "BRL",
38
+ counterparty: "33000167000101",
39
+ spent_today: 9000, // your own count; nobody else keeps it
40
+ payment_network: "visa",
41
+ payment_ref_sha256: await hashPaymentRef(tokenRef) // the network token reference or PAR of this transaction
42
+ });
43
+ // verdict.allowed, verdict.checks (every rule evaluated, with its reason), verdict.not_evaluated (extensions)
44
+ ```
45
+
46
+ ## Payment instruments (card networks, acquirers, issuers)
47
+
48
+ A mandate can list the instruments the agent may pay with. Each one is a network and the SHA-256 of a reference
49
+ the network already gives (network token reference, Payment Account Reference), **never a card number**:
50
+
51
+ ```json
52
+ {
53
+ "actions": ["buy"],
54
+ "max_per_operation": { "amount": 5000, "currency": "BRL" },
55
+ "payment_instruments": [
56
+ { "network": "visa", "ref_sha256": "9f2c…", "label": "purchasing" },
57
+ { "network": "mastercard", "ref_sha256": "a71b…" }
58
+ ]
59
+ }
60
+ ```
61
+
62
+ - `ref_sha256` = lowercase hex SHA-256 of the reference, UTF-8, whitespace removed, case kept. `hashPaymentRef(ref)` does exactly that.
63
+ - `validateMandate` refuses anything that is not a 64-hex hash, labels containing long digit runs, unknown fields and duplicates, so a PAN cannot slip into a credential.
64
+ - At authorization time, hash the reference you received and pass it to `checkAction`: the verdict says whether that instrument belongs to an agent the company (CNPJ, e-CNPJ-signed) authorized, within its limits. When the mandate has no list, or you pass no reference, the rule is not evaluated.
65
+
66
+ With the hosted issuer, the same check comes back signed from
67
+ `GET /v1/agents/{id}/allowed?payment_network=visa&payment_ref_sha256=…`, which is evidence for a later dispute.
68
+
69
+ ## Run your own issuer
70
+
71
+ ```ts
72
+ import {
73
+ newChallenge, verifyAgentSignature, verifyIcpSignature, checkRevocation,
74
+ validateMandate, buildCredential, signCredential, importPrivateKey, generateAttestationKey
75
+ } from "@kya-brasil/core";
76
+
77
+ const key = await generateAttestationKey(); // once; keep privateKeyPkcs8 secret, publish publicJwk
78
+ const ch = newChallenge({ issuer: "https://kya.acquirer.example", agent, cnpj });
79
+ // The agent signs ch.text with its key; the company signs ch.text with its e-CNPJ (detached CMS).
80
+ if (!(await verifyAgentSignature(agent, ch.text, agentSignature))) throw new Error("agent signature");
81
+ const icp = await verifyIcpSignature({ cmsBase64: ecnpjSignature, challenge: ch.text, expectedCnpj: cnpj, anchors });
82
+ // anchors: the ICP-Brasil roots as PEM strings (bundle in the repository: anchors/icp-brasil.pem).
83
+ const crlDer = await fetchCrl(icp.crl_urls); // yours: HTTP + cache
84
+ const rev = await checkRevocation({ crlDer, serial: icp.serial_number, issuerCertificate: icp.issuer_certificate! });
85
+ if (rev.revoked) throw new Error("certificate revoked");
86
+
87
+ const credential = await signCredential(
88
+ buildCredential({
89
+ credential_id: crypto.randomUUID(), agent, cnpj,
90
+ legal_name, registration_status, // from your registration source; "UNVERIFIED" if none answered
91
+ certificate: { sha256: icp.certificate_sha256, subject: icp.certificate_subject, issuer: icp.issuer, signed_at: icp.signed_at },
92
+ mandate: validateMandate(mandate),
93
+ issuer: "https://kya.acquirer.example"
94
+ }),
95
+ await importPrivateKey(key.privateKeyPkcs8), key.keyId
96
+ );
97
+ ```
98
+
99
+ What the library leaves to you: persistence, the challenge's single use and expiry, fetching and caching CRLs,
100
+ the company's registration status (the reference issuer reads the Receita's open data), key custody and the
101
+ publication of your public key. `apps/worker` in the repository is a complete issuer built this way.
102
+
103
+ ## Compatibility
104
+
105
+ Credentials follow `/v1` of the spec: fields are only ever added. Test vectors:
106
+ [docs/vectors/credential-v1.json](https://github.com/celfons/kya-brasil/blob/main/docs/vectors/credential-v1.json).
107
+
108
+ MIT. Issues and contributions: https://github.com/celfons/kya-brasil
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Signed attestation of an answer: "this service returned exactly this JSON at this time".
3
+ * Ed25519 over the SHA-256 of the canonical JSON (keys sorted, no whitespace) of the answer
4
+ * without `attestation` and `receipt`. WebCrypto only, so it runs on Workers and Node alike.
5
+ */
6
+ import type { Attestation } from "./types.js";
7
+ export declare function canonicalize(v: unknown): string;
8
+ export declare function attestedPayload(answer: Record<string, unknown>): Record<string, unknown>;
9
+ /** New Ed25519 key pair: private as PKCS#8 base64 (store as a secret), public as JWK (publish). */
10
+ export declare function generateAttestationKey(): Promise<{
11
+ privateKeyPkcs8: string;
12
+ publicJwk: JsonWebKey;
13
+ keyId: string;
14
+ }>;
15
+ /** key_id = first 16 hex chars of SHA-256 of the raw public key. */
16
+ export declare function keyIdOf(publicJwk: JsonWebKey): Promise<string>;
17
+ export declare function importPrivateKey(pkcs8Base64: string): Promise<CryptoKey>;
18
+ export declare function publicJwkFromPrivate(pkcs8Base64: string): Promise<JsonWebKey>;
19
+ export declare function signAttestation(answer: Record<string, unknown>, privateKey: CryptoKey, keyId: string, verifyUrl: string, now?: Date): Promise<Attestation>;
20
+ /** Recomputes the hash from the answer and checks the signature with the public key. */
21
+ export declare function verifyAttestation(answer: Record<string, unknown>, publicJwk: JsonWebKey): Promise<{
22
+ valid: boolean;
23
+ reason?: string;
24
+ key_id?: string;
25
+ signed_at?: string;
26
+ }>;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Key order of the canonical JSON: UTF-16 code units, exactly what a comparator-less sort() does. Not
3
+ * localeCompare: a locale-dependent order would change the signed bytes between machines and break every
4
+ * signature already issued.
5
+ */
6
+ function byCodeUnit(a, b) {
7
+ if (a < b)
8
+ return -1;
9
+ if (a > b)
10
+ return 1;
11
+ return 0;
12
+ }
13
+ export function canonicalize(v) {
14
+ if (v === null || typeof v !== "object")
15
+ return JSON.stringify(v);
16
+ if (Array.isArray(v))
17
+ return `[${v.map(canonicalize).join(",")}]`;
18
+ const o = v;
19
+ const members = Object.keys(o)
20
+ .filter((k) => o[k] !== undefined)
21
+ .sort(byCodeUnit)
22
+ .map((k) => JSON.stringify(k) + ":" + canonicalize(o[k]));
23
+ return "{" + members.join(",") + "}";
24
+ }
25
+ export function attestedPayload(answer) {
26
+ const { attestation: _a, receipt: _r, ...rest } = answer;
27
+ return rest;
28
+ }
29
+ const b64 = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf)));
30
+ const unb64 = (s) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
31
+ const hex = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
32
+ async function sha256(text) {
33
+ const bytes = new TextEncoder().encode(text);
34
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
35
+ return { hex: hex(digest), bytes: new Uint8Array(digest) };
36
+ }
37
+ /** New Ed25519 key pair: private as PKCS#8 base64 (store as a secret), public as JWK (publish). */
38
+ export async function generateAttestationKey() {
39
+ const kp = (await crypto.subtle.generateKey({ name: "Ed25519" }, true, ["sign", "verify"]));
40
+ const pkcs8 = (await crypto.subtle.exportKey("pkcs8", kp.privateKey));
41
+ const jwk = (await crypto.subtle.exportKey("jwk", kp.publicKey));
42
+ return { privateKeyPkcs8: b64(pkcs8), publicJwk: jwk, keyId: await keyIdOf(jwk) };
43
+ }
44
+ /** key_id = first 16 hex chars of SHA-256 of the raw public key. */
45
+ export async function keyIdOf(publicJwk) {
46
+ return (await sha256(publicJwk.x ?? "")).hex.slice(0, 16);
47
+ }
48
+ export async function importPrivateKey(pkcs8Base64) {
49
+ return crypto.subtle.importKey("pkcs8", unb64(pkcs8Base64), { name: "Ed25519" }, true, ["sign"]);
50
+ }
51
+ export async function publicJwkFromPrivate(pkcs8Base64) {
52
+ const priv = await crypto.subtle.importKey("pkcs8", unb64(pkcs8Base64), { name: "Ed25519" }, true, ["sign"]);
53
+ const jwk = (await crypto.subtle.exportKey("jwk", priv));
54
+ const { d: _d, ...pub } = jwk; // drop the private part
55
+ return { ...pub, key_ops: ["verify"] };
56
+ }
57
+ export async function signAttestation(answer, privateKey, keyId, verifyUrl, now = new Date()) {
58
+ const payload = attestedPayload(answer);
59
+ const { hex: digestHex, bytes } = await sha256(canonicalize(payload));
60
+ const signed_at = now.toISOString();
61
+ const message = new TextEncoder().encode(`${digestHex}\n${signed_at}`);
62
+ const sig = await crypto.subtle.sign({ name: "Ed25519" }, privateKey, message);
63
+ void bytes;
64
+ return { algorithm: "ed25519", key_id: keyId, sha256: digestHex, signed_at, signature: b64(sig), verify_url: verifyUrl };
65
+ }
66
+ /** Recomputes the hash from the answer and checks the signature with the public key. */
67
+ export async function verifyAttestation(answer, publicJwk) {
68
+ const att = answer.attestation;
69
+ if (!att || att.algorithm !== "ed25519")
70
+ return { valid: false, reason: "no ed25519 attestation in the answer" };
71
+ const { hex: digestHex } = await sha256(canonicalize(attestedPayload(answer)));
72
+ if (digestHex !== att.sha256)
73
+ return { valid: false, reason: "content hash mismatch: the answer was modified", key_id: att.key_id, signed_at: att.signed_at };
74
+ const keyId = await keyIdOf(publicJwk);
75
+ if (keyId !== att.key_id)
76
+ return { valid: false, reason: `signed with key ${att.key_id}, verifying with ${keyId}`, key_id: att.key_id, signed_at: att.signed_at };
77
+ const key = await crypto.subtle.importKey("jwk", publicJwk, { name: "Ed25519" }, true, ["verify"]);
78
+ const ok = await crypto.subtle.verify({ name: "Ed25519" }, key, unb64(att.signature), new TextEncoder().encode(`${digestHex}\n${att.signed_at}`));
79
+ return ok ? { valid: true, key_id: att.key_id, signed_at: att.signed_at } : { valid: false, reason: "signature does not verify", key_id: att.key_id, signed_at: att.signed_at };
80
+ }
@@ -0,0 +1,31 @@
1
+ import { type AgentKey } from "./types.js";
2
+ export declare const CHALLENGE_TTL_SECONDS = 900;
3
+ export interface Challenge {
4
+ challenge_id: string;
5
+ text: string;
6
+ expires_at: string;
7
+ }
8
+ export declare function normalizeAgentKey(input: {
9
+ kind?: unknown;
10
+ id?: unknown;
11
+ }): AgentKey;
12
+ /** The exact text the agent signs. Line-based so wallets show it readably. */
13
+ export declare function challengeText(p: {
14
+ issuer: string;
15
+ challenge_id: string;
16
+ agent: AgentKey;
17
+ cnpj: string;
18
+ expires_at: string;
19
+ }): string;
20
+ export declare function newChallenge(p: {
21
+ issuer: string;
22
+ agent: AgentKey;
23
+ cnpj: string;
24
+ now?: Date;
25
+ }): Challenge;
26
+ /** True when `signature` is the agent key's signature over `text`. Never throws on a bad signature. */
27
+ export declare function verifyAgentSignature(agent: AgentKey, text: string, signature: string): Promise<boolean>;
28
+ export declare const base64url: {
29
+ encode: (bytes: Uint8Array) => string;
30
+ decode: (s: string) => Uint8Array<ArrayBuffer>;
31
+ };
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Proof of key possession by the agent. The issuer hands out a one-time challenge; the agent
3
+ * signs it with its key. Three key kinds:
4
+ * evm EIP-191 personal_sign over the challenge text; address recovered and compared
5
+ * web-bot-auth Ed25519 (RFC 9421 key material) raw signature over the challenge bytes
6
+ * erc8004 EIP-191 by the agent's on-chain agentWallet (resolved by the caller, see erc8004.ts)
7
+ * The challenge text binds agent key, CNPJ, issuer and expiry, so a signature cannot be replayed
8
+ * for another company or another issuer.
9
+ */
10
+ import { verifyMessage, getAddress, isAddress } from "viem";
11
+ import { KyaError } from "./types.js";
12
+ import { parseErc8004Id } from "./erc8004.js";
13
+ export const CHALLENGE_TTL_SECONDS = 900;
14
+ /** Drops trailing "=" padding with a linear scan (no regex). */
15
+ const stripPadding = (s) => {
16
+ let end = s.length;
17
+ while (end > 0 && s[end - 1] === "=")
18
+ end--;
19
+ return s.slice(0, end);
20
+ };
21
+ const b64u = (bytes) => stripPadding(btoa(String.fromCodePoint(...bytes)).replaceAll("+", "-").replaceAll("/", "_"));
22
+ const unb64u = (s) => {
23
+ const b64 = s.replaceAll("-", "+").replaceAll("_", "/");
24
+ return Uint8Array.from(atob(b64.padEnd(Math.ceil(b64.length / 4) * 4, "=")), (c) => c.codePointAt(0) ?? 0);
25
+ };
26
+ export function normalizeAgentKey(input) {
27
+ if (input.kind === "evm") {
28
+ if (typeof input.id !== "string" || !isAddress(input.id))
29
+ throw new KyaError("INVALID_INPUT", "agent.id must be a 0x EVM address for kind evm");
30
+ return { kind: "evm", id: getAddress(input.id) };
31
+ }
32
+ if (input.kind === "web-bot-auth") {
33
+ if (typeof input.id !== "string")
34
+ throw new KyaError("INVALID_INPUT", "agent.id must be the Ed25519 public key (base64url, 32 bytes) for kind web-bot-auth");
35
+ let raw;
36
+ try {
37
+ raw = unb64u(input.id);
38
+ }
39
+ catch {
40
+ throw new KyaError("INVALID_INPUT", "agent.id is not base64url");
41
+ }
42
+ if (raw.length !== 32)
43
+ throw new KyaError("INVALID_INPUT", "agent.id must decode to 32 bytes");
44
+ return { kind: "web-bot-auth", id: b64u(raw) };
45
+ }
46
+ if (input.kind === "erc8004") {
47
+ const { id } = parseErc8004Id(input.id);
48
+ return { kind: "erc8004", id };
49
+ }
50
+ throw new KyaError("INVALID_INPUT", "agent.kind must be evm, web-bot-auth or erc8004");
51
+ }
52
+ /** The exact text the agent signs. Line-based so wallets show it readably. */
53
+ export function challengeText(p) {
54
+ return [
55
+ "KYA Brasil: prove this agent key acts for a company",
56
+ `issuer: ${p.issuer}`,
57
+ `challenge: ${p.challenge_id}`,
58
+ `agent: ${p.agent.kind}:${p.agent.id}`,
59
+ `cnpj: ${p.cnpj}`,
60
+ `expires: ${p.expires_at}`
61
+ ].join("\n");
62
+ }
63
+ export function newChallenge(p) {
64
+ const now = p.now ?? new Date();
65
+ const challenge_id = crypto.randomUUID();
66
+ const expires_at = new Date(now.getTime() + CHALLENGE_TTL_SECONDS * 1000).toISOString();
67
+ return { challenge_id, expires_at, text: challengeText({ issuer: p.issuer, challenge_id, agent: p.agent, cnpj: p.cnpj, expires_at }) };
68
+ }
69
+ /** True when `signature` is the agent key's signature over `text`. Never throws on a bad signature. */
70
+ export async function verifyAgentSignature(agent, text, signature) {
71
+ try {
72
+ if (agent.kind === "evm" || agent.kind === "erc8004") {
73
+ if (!/^0x[0-9a-fA-F]{130}$/.test(signature))
74
+ return false;
75
+ const address = agent.kind === "evm" ? agent.id : agent.wallet;
76
+ if (!address)
77
+ return false; // erc8004 without a resolved wallet: nothing to compare against
78
+ return await verifyMessage({ address: address, message: text, signature: signature });
79
+ }
80
+ const raw = unb64u(agent.id);
81
+ const key = await crypto.subtle.importKey("raw", raw, { name: "Ed25519" }, false, ["verify"]);
82
+ const sig = unb64u(signature);
83
+ if (sig.length !== 64)
84
+ return false;
85
+ return await crypto.subtle.verify({ name: "Ed25519" }, key, sig, new TextEncoder().encode(text));
86
+ }
87
+ catch {
88
+ return false;
89
+ }
90
+ }
91
+ export const base64url = { encode: b64u, decode: unb64u };
package/dist/cnpj.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * CNPJ (Cadastro Nacional da Pessoa Jurídica) helpers.
3
+ *
4
+ * Since 2026 the Receita Federal accepts alphanumeric CNPJs (letters in the first
5
+ * 12 positions, digits in the 2 check digits). The check-digit algorithm maps each
6
+ * character to (ASCII code - 48), so digits keep their value and letters get 17..42.
7
+ */
8
+ /** Strip punctuation and whitespace, upper-case letters. Does not validate. */
9
+ export declare function normalizeCnpj(input: string): string;
10
+ /** True when the string is a syntactically valid (numeric or alphanumeric) CNPJ. */
11
+ export declare function isValidCnpj(input: string): boolean;
12
+ /** Format as XX.XXX.XXX/XXXX-XX. Input must already be 14 chars. */
13
+ export declare function formatCnpj(cnpj: string): string;
14
+ /** First 8 characters: identifies the company; the remaining 4 identify the branch (0001 = HQ). */
15
+ export declare function cnpjRoot(cnpj: string): string;
16
+ export declare function isHeadquarters(cnpj: string): boolean;
package/dist/cnpj.js ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * CNPJ (Cadastro Nacional da Pessoa Jurídica) helpers.
3
+ *
4
+ * Since 2026 the Receita Federal accepts alphanumeric CNPJs (letters in the first
5
+ * 12 positions, digits in the 2 check digits). The check-digit algorithm maps each
6
+ * character to (ASCII code - 48), so digits keep their value and letters get 17..42.
7
+ */
8
+ const WEIGHTS_1 = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
9
+ const WEIGHTS_2 = [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
10
+ /** Strip punctuation and whitespace, upper-case letters. Does not validate. */
11
+ export function normalizeCnpj(input) {
12
+ return input.replace(/[^0-9A-Za-z]/g, "").toUpperCase();
13
+ }
14
+ function charValue(ch) {
15
+ return ch.charCodeAt(0) - 48;
16
+ }
17
+ function checkDigit(base, weights) {
18
+ let sum = 0;
19
+ for (let i = 0; i < weights.length; i++) {
20
+ sum += charValue(base[i]) * weights[i];
21
+ }
22
+ const mod = sum % 11;
23
+ return mod < 2 ? 0 : 11 - mod;
24
+ }
25
+ /** True when the string is a syntactically valid (numeric or alphanumeric) CNPJ. */
26
+ export function isValidCnpj(input) {
27
+ const cnpj = normalizeCnpj(input);
28
+ if (!/^[0-9A-Z]{12}[0-9]{2}$/.test(cnpj))
29
+ return false;
30
+ if (/^(.)\1{13}$/.test(cnpj))
31
+ return false;
32
+ const d1 = checkDigit(cnpj.slice(0, 12), WEIGHTS_1);
33
+ const d2 = checkDigit(cnpj.slice(0, 12) + String(d1), WEIGHTS_2);
34
+ return cnpj[12] === String(d1) && cnpj[13] === String(d2);
35
+ }
36
+ /** Format as XX.XXX.XXX/XXXX-XX. Input must already be 14 chars. */
37
+ export function formatCnpj(cnpj) {
38
+ const c = normalizeCnpj(cnpj);
39
+ if (c.length !== 14)
40
+ throw new Error(`Invalid CNPJ length: ${c.length}`);
41
+ return `${c.slice(0, 2)}.${c.slice(2, 5)}.${c.slice(5, 8)}/${c.slice(8, 12)}-${c.slice(12)}`;
42
+ }
43
+ /** First 8 characters: identifies the company; the remaining 4 identify the branch (0001 = HQ). */
44
+ export function cnpjRoot(cnpj) {
45
+ return normalizeCnpj(cnpj).slice(0, 8);
46
+ }
47
+ export function isHeadquarters(cnpj) {
48
+ return normalizeCnpj(cnpj).slice(8, 12) === "0001";
49
+ }
@@ -0,0 +1,43 @@
1
+ import type { Credential, CredentialStatus, KybSnapshot, Mandate, AgentKey } from "./types.js";
2
+ export declare const CREDENTIAL_TTL_DAYS = 90;
3
+ export declare function buildCredential(p: {
4
+ credential_id: string;
5
+ agent: AgentKey;
6
+ cnpj: string;
7
+ legal_name: string;
8
+ registration_status: string;
9
+ certificate: {
10
+ sha256: string;
11
+ subject: string;
12
+ issuer: string;
13
+ signed_at: string;
14
+ };
15
+ mandate: Mandate;
16
+ /** /v1 compatibility only; leave out (null). */
17
+ kyb?: KybSnapshot | null;
18
+ issuer: string;
19
+ now?: Date;
20
+ status?: CredentialStatus;
21
+ status_reason?: string | null;
22
+ }): Credential;
23
+ export declare function signCredential(c: Credential, privateKey: CryptoKey, keyId: string, now?: Date): Promise<Credential>;
24
+ export declare function verifyCredentialSignature(c: Credential, publicJwk: JsonWebKey): Promise<{
25
+ valid: boolean;
26
+ reason?: string;
27
+ key_id?: string;
28
+ signed_at?: string;
29
+ }>;
30
+ /** Stable hash of the credential content (what the signature covers). */
31
+ export declare function credentialDigest(c: Credential): Promise<string>;
32
+ /**
33
+ * Whether the company can still answer for an agent (D-15): only its existence as a legal entity,
34
+ * i.e. registration ATIVA at the Receita Federal. Sanctions, debt and risk are facts about the company
35
+ * for the verifier to weigh with its own screening, never a reason to deny or suspend the credential.
36
+ */
37
+ /** D-24: statuses that mean "not known", not "not active": the check was off or no source answered. */
38
+ export declare const UNKNOWN_REGISTRATION: string[];
39
+ export declare function principalDecision(registration_status: string): {
40
+ ok: boolean;
41
+ reason: string | null;
42
+ };
43
+ export declare function effectiveStatus(c: Pick<Credential, "status" | "expires_at">, now?: Date): CredentialStatus;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Credential assembly, signing and verification. The signature is an Ed25519 attestation over the
3
+ * SHA-256 of the canonical JSON without `attestation`, so the public key document at
4
+ * /.well-known/kya.json and the SDK's verifyAttestation() verify credentials and verdicts alike.
5
+ */
6
+ import { attestedPayload, canonicalize, signAttestation, verifyAttestation } from "./attestation.js";
7
+ import { formatCnpj, normalizeCnpj } from "./cnpj.js";
8
+ export const CREDENTIAL_TTL_DAYS = 90;
9
+ export function buildCredential(p) {
10
+ const now = p.now ?? new Date();
11
+ const cnpj = normalizeCnpj(p.cnpj);
12
+ const ttl = new Date(now.getTime() + CREDENTIAL_TTL_DAYS * 86400000);
13
+ const mandateEnd = p.mandate.valid_until ? new Date(p.mandate.valid_until) : null;
14
+ const expires = mandateEnd && mandateEnd < ttl ? mandateEnd : ttl;
15
+ return {
16
+ credential_id: p.credential_id,
17
+ version: 1,
18
+ agent: p.agent,
19
+ principal: { cnpj, cnpj_formatted: formatCnpj(cnpj), legal_name: p.legal_name, registration_status: p.registration_status },
20
+ authorized_by: { method: "icp-brasil-e-cnpj", certificate_sha256: p.certificate.sha256, certificate_subject: p.certificate.subject, issuer: p.certificate.issuer, signed_at: p.certificate.signed_at },
21
+ mandate: p.mandate,
22
+ kyb_snapshot: p.kyb ?? null,
23
+ status: p.status ?? "active",
24
+ status_reason: p.status_reason ?? null,
25
+ issued_at: now.toISOString(),
26
+ expires_at: expires.toISOString(),
27
+ issuer: p.issuer,
28
+ verify_url: `${p.issuer}/v1/agents/${p.credential_id}`,
29
+ revocation_url: `${p.issuer}/v1/agents/${p.credential_id}/status`
30
+ };
31
+ }
32
+ export async function signCredential(c, privateKey, keyId, now = new Date()) {
33
+ const { attestation: _a, ...payload } = c;
34
+ const attestation = await signAttestation(payload, privateKey, keyId, `${c.issuer}/v1/verify`, now);
35
+ return { ...payload, attestation };
36
+ }
37
+ export async function verifyCredentialSignature(c, publicJwk) {
38
+ return verifyAttestation(c, publicJwk);
39
+ }
40
+ /** Stable hash of the credential content (what the signature covers). */
41
+ export async function credentialDigest(c) {
42
+ const bytes = new TextEncoder().encode(canonicalize(attestedPayload(c)));
43
+ const d = await crypto.subtle.digest("SHA-256", bytes);
44
+ return [...new Uint8Array(d)].map((b) => b.toString(16).padStart(2, "0")).join("");
45
+ }
46
+ /**
47
+ * Whether the company can still answer for an agent (D-15): only its existence as a legal entity,
48
+ * i.e. registration ATIVA at the Receita Federal. Sanctions, debt and risk are facts about the company
49
+ * for the verifier to weigh with its own screening, never a reason to deny or suspend the credential.
50
+ */
51
+ /** D-24: statuses that mean "not known", not "not active": the check was off or no source answered. */
52
+ export const UNKNOWN_REGISTRATION = ["UNVERIFIED", "NOT_CHECKED"];
53
+ export function principalDecision(registration_status) {
54
+ // Only a confirmed status other than ATIVA blocks; an unknown one is carried as a warning (D-24).
55
+ if (UNKNOWN_REGISTRATION.includes(registration_status))
56
+ return { ok: true, reason: null };
57
+ if (registration_status !== "ATIVA")
58
+ return { ok: false, reason: `company is ${registration_status} at the Receita Federal` };
59
+ return { ok: true, reason: null };
60
+ }
61
+ export function effectiveStatus(c, now = new Date()) {
62
+ if (c.status === "active" && Date.parse(c.expires_at) < now.getTime())
63
+ return "expired";
64
+ return c.status;
65
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * ERC-8004 (Trustless Agents) identities, read-only. An agent registered in an Identity Registry
3
+ * (an ERC-721 token) is addressed as
4
+ *
5
+ * eip155:<chainId>:<identityRegistry>:<agentId>
6
+ *
7
+ * and signs the challenge with its `agentWallet` (the registry's reserved metadata key; defaults to the
8
+ * token owner). The issuer resolves that wallet on chain at registration, at proof and in the daily
9
+ * review, so a credential never outlives a wallet change. Nothing is written on chain in this version.
10
+ */
11
+ import { type Transport } from "viem";
12
+ /** Canonical registries (deterministic deployment, same address on every chain of its kind). */
13
+ export declare const ERC8004_IDENTITY_REGISTRIES: Record<number, `0x${string}`>;
14
+ export declare const DEFAULT_RPC_URLS: Record<number, string>;
15
+ export declare const ERC8004_IDENTITY_ABI: readonly [{
16
+ readonly type: "function";
17
+ readonly name: "getAgentWallet";
18
+ readonly stateMutability: "view";
19
+ readonly inputs: readonly [{
20
+ readonly name: "agentId";
21
+ readonly type: "uint256";
22
+ }];
23
+ readonly outputs: readonly [{
24
+ readonly type: "address";
25
+ }];
26
+ }, {
27
+ readonly type: "function";
28
+ readonly name: "ownerOf";
29
+ readonly stateMutability: "view";
30
+ readonly inputs: readonly [{
31
+ readonly name: "tokenId";
32
+ readonly type: "uint256";
33
+ }];
34
+ readonly outputs: readonly [{
35
+ readonly type: "address";
36
+ }];
37
+ }, {
38
+ readonly type: "function";
39
+ readonly name: "tokenURI";
40
+ readonly stateMutability: "view";
41
+ readonly inputs: readonly [{
42
+ readonly name: "tokenId";
43
+ readonly type: "uint256";
44
+ }];
45
+ readonly outputs: readonly [{
46
+ readonly type: "string";
47
+ }];
48
+ }];
49
+ export interface Erc8004Id {
50
+ chainId: number;
51
+ registry: `0x${string}`;
52
+ agentId: bigint;
53
+ }
54
+ /** Parses and normalizes `eip155:<chainId>:<registry>:<agentId>`; throws INVALID_INPUT otherwise. */
55
+ export declare function parseErc8004Id(id: unknown): Erc8004Id & {
56
+ id: string;
57
+ };
58
+ export interface ResolveOptions {
59
+ /** RPC URL for the chain; defaults to DEFAULT_RPC_URLS[chainId]. */
60
+ rpcUrl?: string;
61
+ /** Test hook: a viem transport (e.g. custom({ request })) instead of HTTP. */
62
+ transport?: Transport;
63
+ }
64
+ /**
65
+ * The wallet that signs for the agent: `getAgentWallet(agentId)` (ERC-8004 reserved metadata), falling
66
+ * back to `ownerOf(agentId)` on registries without the getter. Throws NOT_FOUND when the token does not
67
+ * exist and UPSTREAM_ERROR when the chain cannot be read.
68
+ */
69
+ export declare function resolveAgentWallet(ref: Erc8004Id, opts?: ResolveOptions): Promise<`0x${string}`>;
70
+ /** Test helper: a viem transport answering eth_call from a map of function selector → ABI-encoded result. */
71
+ export declare function mockTransport(answers: Record<string, string | Error>): Transport;