@kya-brasil/core 0.17.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 +92 -0
- package/dist/cosign.d.ts +50 -0
- package/dist/cosign.js +117 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/issuance.d.ts +182 -0
- package/dist/issuance.js +333 -0
- package/dist/principal-proof.d.ts +6 -0
- package/dist/principal-proof.js +6 -2
- package/dist/types.d.ts +13 -0
- package/package.json +3 -3
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,97 @@ 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
|
+
**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
|
+
|
|
126
|
+
Delegation, issued by the parent agent with its own key:
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
import { buildDelegation, assembleDelegation, encodeDelegationHeader } from "@kya-brasil/core";
|
|
130
|
+
|
|
131
|
+
const prepared = await buildDelegation({ credential }, {
|
|
132
|
+
delegate: { kind: "evm", id: subAgentAddress },
|
|
133
|
+
mandate: { ...narrowerTerms, delegation: null }, // all fields required; refused if broader than the parent's
|
|
134
|
+
ttl_seconds: 600,
|
|
135
|
+
now: new Date()
|
|
136
|
+
});
|
|
137
|
+
const chain = await assembleDelegation({ credential }, prepared, await agentAccount.signMessage({ message: prepared.text }), new Date());
|
|
138
|
+
// KYA-Delegation: encodeDelegationHeader(chain); verifiers use verifyDelegationChain, unchanged.
|
|
139
|
+
```
|
|
140
|
+
|
|
49
141
|
## Without trusting the issuer
|
|
50
142
|
|
|
51
143
|
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/cosign.d.ts
ADDED
|
@@ -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
package/dist/index.js
CHANGED
|
@@ -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[]>;
|
package/dist/issuance.js
ADDED
|
@@ -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
|
+
}
|
|
@@ -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";
|
package/dist/principal-proof.js
CHANGED
|
@@ -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
|
-
|
|
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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kya-brasil/core",
|
|
3
|
-
"version": "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.19.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.
|
|
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"
|