@kya-brasil/core 0.13.0 → 0.15.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 +23 -1
- package/dist/challenge.d.ts +11 -1
- package/dist/challenge.js +25 -4
- package/dist/credential.d.ts +14 -0
- package/dist/credential.js +20 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/person.d.ts +3 -1
- package/dist/person.js +5 -2
- package/dist/principal-proof.d.ts +38 -0
- package/dist/principal-proof.js +97 -0
- package/dist/types.d.ts +6 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -25,10 +25,13 @@ A credential from any KYA issuer (the hosted one, yours, a partner's) is checked
|
|
|
25
25
|
published at `<issuer>/.well-known/kya.json`.
|
|
26
26
|
|
|
27
27
|
```ts
|
|
28
|
-
import { verifyCredentialSignature, effectiveStatus, checkAction, hashPaymentRef } from "@kya-brasil/core";
|
|
28
|
+
import { verifyCredentialSignature, verifyMandateAuthorization, effectiveStatus, checkAction, hashPaymentRef } from "@kya-brasil/core";
|
|
29
29
|
|
|
30
30
|
const sig = await verifyCredentialSignature(credential, issuerPublicJwk); // { valid, key_id, signed_at }
|
|
31
31
|
if (!sig.valid || effectiveStatus(credential) !== "active") deny();
|
|
32
|
+
// D-33: the e-CNPJ / e-CPF signature covers the mandate when the credential carries its hash.
|
|
33
|
+
const auth = await verifyMandateAuthorization(credential); // "signed" | "mismatch" | "not_covered" (issued before D-33)
|
|
34
|
+
if (auth === "mismatch") deny(); // decide yourself whether "not_covered" (issuer attestation only) is enough
|
|
32
35
|
// For live status (revoked or suspended after issuance) read credential.verify_url, or accept that risk until expires_at.
|
|
33
36
|
|
|
34
37
|
const verdict = checkAction(credential.mandate, {
|
|
@@ -43,6 +46,25 @@ const verdict = checkAction(credential.mandate, {
|
|
|
43
46
|
// verdict.allowed, verdict.checks (every rule evaluated, with its reason), verdict.not_evaluated (extensions)
|
|
44
47
|
```
|
|
45
48
|
|
|
49
|
+
## Without trusting the issuer
|
|
50
|
+
|
|
51
|
+
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
|
|
52
|
+
`.p7s`. When the principal hands them to its agent, you can check the authorization against ICP-Brasil alone (SPEC
|
|
53
|
+
section 19), and the issuer's key vouches only for the credential's status:
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
import { decodePrincipalProofHeader, verifyPrincipalProof, checkRevocation } from "@kya-brasil/core";
|
|
57
|
+
|
|
58
|
+
const proof = decodePrincipalProofHeader(request.headers.get("KYA-Principal-Proof")!);
|
|
59
|
+
const r = await verifyPrincipalProof({ credential, proof, anchors: icpBrasilAnchors, now: trustedClock() });
|
|
60
|
+
// r.ok; r.checks: signature, certificate, agent, principal, issuer, mandate (each with its reason)
|
|
61
|
+
// then the certificate's revocation, with a CRL you fetched from r.revocation.crl_urls:
|
|
62
|
+
// await checkRevocation({ crlDer, serial: r.revocation!.serial_number, issuerCertificate: r.revocation!.issuer_certificate!, pastNextUpdate: "refuse" })
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The CMS carries the signer's certificate (personal data): the principal decides when to share it, and you use it for
|
|
66
|
+
this check only. The result carries nothing personal.
|
|
67
|
+
|
|
46
68
|
## Delegation chains (sub-agents)
|
|
47
69
|
|
|
48
70
|
A mandate with `delegation: { max_depth, max_ttl_seconds }` lets the agent hand a narrower mandate to another key
|
package/dist/challenge.d.ts
CHANGED
|
@@ -9,20 +9,30 @@ export declare function normalizeAgentKey(input: {
|
|
|
9
9
|
kind?: unknown;
|
|
10
10
|
id?: unknown;
|
|
11
11
|
}): AgentKey;
|
|
12
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* The exact text the agent and the e-CNPJ sign. Line-based so wallets show it readably. Since D-33 it carries
|
|
14
|
+
* the mandate's SHA-256, so the certificate signature covers the powers granted, not only the binding.
|
|
15
|
+
*/
|
|
13
16
|
export declare function challengeText(p: {
|
|
14
17
|
issuer: string;
|
|
15
18
|
challenge_id: string;
|
|
16
19
|
agent: AgentKey;
|
|
17
20
|
cnpj: string;
|
|
21
|
+
mandate_sha256: string;
|
|
18
22
|
expires_at: string;
|
|
19
23
|
}): string;
|
|
20
24
|
export declare function newChallenge(p: {
|
|
21
25
|
issuer: string;
|
|
22
26
|
agent: AgentKey;
|
|
23
27
|
cnpj: string;
|
|
28
|
+
mandate_sha256: string;
|
|
24
29
|
now?: Date;
|
|
25
30
|
}): Challenge;
|
|
31
|
+
/**
|
|
32
|
+
* The mandate SHA-256 a signed challenge text commits to (its `mandate-sha256:` line), or null when the text
|
|
33
|
+
* has none (a challenge from before D-33). The issuer compares it with the mandate it is about to issue.
|
|
34
|
+
*/
|
|
35
|
+
export declare function mandateSha256OfChallenge(text: string): string | null;
|
|
26
36
|
/** True when `signature` is the agent key's signature over `text`. Never throws on a bad signature. */
|
|
27
37
|
export declare function verifyAgentSignature(agent: AgentKey, text: string, signature: string): Promise<boolean>;
|
|
28
38
|
export declare const base64url: {
|
package/dist/challenge.js
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* evm EIP-191 personal_sign over the challenge text; address recovered and compared
|
|
5
5
|
* web-bot-auth Ed25519 (RFC 9421 key material) raw signature over the challenge bytes
|
|
6
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
|
|
8
|
-
* for another company or
|
|
7
|
+
* The challenge text binds agent key, CNPJ, issuer, expiry and the mandate (its SHA-256, D-33), so a
|
|
8
|
+
* signature cannot be replayed for another company, another issuer or other powers.
|
|
9
9
|
*/
|
|
10
10
|
import { verifyMessage, getAddress, isAddress } from "viem";
|
|
11
11
|
import { KyaError } from "./types.js";
|
|
@@ -49,7 +49,16 @@ export function normalizeAgentKey(input) {
|
|
|
49
49
|
}
|
|
50
50
|
throw new KyaError("INVALID_INPUT", "agent.kind must be evm, web-bot-auth or erc8004");
|
|
51
51
|
}
|
|
52
|
-
|
|
52
|
+
const HEX64 = /^[0-9a-f]{64}$/;
|
|
53
|
+
function requireMandateSha256(h) {
|
|
54
|
+
if (typeof h !== "string" || !HEX64.test(h))
|
|
55
|
+
throw new KyaError("INVALID_INPUT", "mandate_sha256 must be 64 lowercase hex characters (see mandateSha256)");
|
|
56
|
+
return h;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The exact text the agent and the e-CNPJ sign. Line-based so wallets show it readably. Since D-33 it carries
|
|
60
|
+
* the mandate's SHA-256, so the certificate signature covers the powers granted, not only the binding.
|
|
61
|
+
*/
|
|
53
62
|
export function challengeText(p) {
|
|
54
63
|
return [
|
|
55
64
|
"KYA Brasil: prove this agent key acts for a company",
|
|
@@ -57,6 +66,7 @@ export function challengeText(p) {
|
|
|
57
66
|
`challenge: ${p.challenge_id}`,
|
|
58
67
|
`agent: ${p.agent.kind}:${p.agent.id}`,
|
|
59
68
|
`cnpj: ${p.cnpj}`,
|
|
69
|
+
`mandate-sha256: ${requireMandateSha256(p.mandate_sha256)}`,
|
|
60
70
|
`expires: ${p.expires_at}`
|
|
61
71
|
].join("\n");
|
|
62
72
|
}
|
|
@@ -64,7 +74,18 @@ export function newChallenge(p) {
|
|
|
64
74
|
const now = p.now ?? new Date();
|
|
65
75
|
const challenge_id = crypto.randomUUID();
|
|
66
76
|
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 }) };
|
|
77
|
+
return { challenge_id, expires_at, text: challengeText({ issuer: p.issuer, challenge_id, agent: p.agent, cnpj: p.cnpj, mandate_sha256: p.mandate_sha256, expires_at }) };
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The mandate SHA-256 a signed challenge text commits to (its `mandate-sha256:` line), or null when the text
|
|
81
|
+
* has none (a challenge from before D-33). The issuer compares it with the mandate it is about to issue.
|
|
82
|
+
*/
|
|
83
|
+
export function mandateSha256OfChallenge(text) {
|
|
84
|
+
const lines = text.split("\n").filter((l) => l.startsWith("mandate-sha256: "));
|
|
85
|
+
if (lines.length !== 1)
|
|
86
|
+
return null;
|
|
87
|
+
const h = lines[0].slice("mandate-sha256: ".length);
|
|
88
|
+
return HEX64.test(h) ? h : null;
|
|
68
89
|
}
|
|
69
90
|
/** True when `signature` is the agent key's signature over `text`. Never throws on a bad signature. */
|
|
70
91
|
export async function verifyAgentSignature(agent, text, signature) {
|
package/dist/credential.d.ts
CHANGED
|
@@ -13,6 +13,8 @@ export declare function buildCredential(p: {
|
|
|
13
13
|
signed_at: string;
|
|
14
14
|
};
|
|
15
15
|
mandate: Mandate;
|
|
16
|
+
/** D-33: the mandate SHA-256 the signed challenge carried (mandateSha256OfChallenge). */
|
|
17
|
+
mandate_sha256?: string;
|
|
16
18
|
/** /v1 compatibility only; leave out (null). */
|
|
17
19
|
kyb?: KybSnapshot | null;
|
|
18
20
|
issuer: string;
|
|
@@ -30,6 +32,8 @@ export declare function buildPersonCredential(p: {
|
|
|
30
32
|
signed_at: string;
|
|
31
33
|
};
|
|
32
34
|
mandate: Mandate;
|
|
35
|
+
/** D-33: the mandate SHA-256 the signed challenge carried (mandateSha256OfChallenge). */
|
|
36
|
+
mandate_sha256?: string;
|
|
33
37
|
issuer: string;
|
|
34
38
|
now?: Date;
|
|
35
39
|
}): Credential;
|
|
@@ -54,3 +58,13 @@ export declare function principalDecision(registration_status: string): {
|
|
|
54
58
|
reason: string | null;
|
|
55
59
|
};
|
|
56
60
|
export declare function effectiveStatus(c: Pick<Credential, "status" | "expires_at">, now?: Date): CredentialStatus;
|
|
61
|
+
/** D-33: SHA-256 (lowercase hex) of the canonical JSON (section 7) of a mandate; what the challenge commits to. */
|
|
62
|
+
export declare function mandateSha256(mandate: Mandate): Promise<string>;
|
|
63
|
+
/**
|
|
64
|
+
* Offline: does the ICP-Brasil signature behind this credential cover its mandate (D-33)?
|
|
65
|
+
* "signed" authorized_by.mandate_sha256 equals the SHA-256 of credential.mandate;
|
|
66
|
+
* "mismatch" it does not: the mandate is not the one the principal signed (refuse the credential);
|
|
67
|
+
* "not_covered" the credential predates D-33 and carries no hash: the mandate rests on the issuer's attestation.
|
|
68
|
+
* The certificate signature itself is verified by the issuer at issuance; the signed .p7s stays with the principal.
|
|
69
|
+
*/
|
|
70
|
+
export declare function verifyMandateAuthorization(c: Pick<Credential, "mandate" | "authorized_by">): Promise<"signed" | "mismatch" | "not_covered">;
|
package/dist/credential.js
CHANGED
|
@@ -17,7 +17,7 @@ export function buildCredential(p) {
|
|
|
17
17
|
version: 1,
|
|
18
18
|
agent: p.agent,
|
|
19
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 },
|
|
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, ...(p.mandate_sha256 ? { mandate_sha256: p.mandate_sha256 } : {}) },
|
|
21
21
|
mandate: p.mandate,
|
|
22
22
|
kyb_snapshot: p.kyb ?? null,
|
|
23
23
|
status: p.status ?? "active",
|
|
@@ -40,7 +40,7 @@ export function buildPersonCredential(p) {
|
|
|
40
40
|
version: 1,
|
|
41
41
|
agent: p.agent,
|
|
42
42
|
principal: { kind: "person", verified_by: "icp-brasil-e-cpf" },
|
|
43
|
-
authorized_by: { method: "icp-brasil-e-cpf", certificate_sha256: p.certificate.sha256, issuer: p.certificate.issuer, signed_at: p.certificate.signed_at },
|
|
43
|
+
authorized_by: { method: "icp-brasil-e-cpf", certificate_sha256: p.certificate.sha256, issuer: p.certificate.issuer, signed_at: p.certificate.signed_at, ...(p.mandate_sha256 ? { mandate_sha256: p.mandate_sha256 } : {}) },
|
|
44
44
|
mandate: p.mandate,
|
|
45
45
|
kyb_snapshot: null,
|
|
46
46
|
status: "active",
|
|
@@ -86,3 +86,21 @@ export function effectiveStatus(c, now = new Date()) {
|
|
|
86
86
|
return "expired";
|
|
87
87
|
return c.status;
|
|
88
88
|
}
|
|
89
|
+
/** D-33: SHA-256 (lowercase hex) of the canonical JSON (section 7) of a mandate; what the challenge commits to. */
|
|
90
|
+
export async function mandateSha256(mandate) {
|
|
91
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonicalize(mandate)));
|
|
92
|
+
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Offline: does the ICP-Brasil signature behind this credential cover its mandate (D-33)?
|
|
96
|
+
* "signed" authorized_by.mandate_sha256 equals the SHA-256 of credential.mandate;
|
|
97
|
+
* "mismatch" it does not: the mandate is not the one the principal signed (refuse the credential);
|
|
98
|
+
* "not_covered" the credential predates D-33 and carries no hash: the mandate rests on the issuer's attestation.
|
|
99
|
+
* The certificate signature itself is verified by the issuer at issuance; the signed .p7s stays with the principal.
|
|
100
|
+
*/
|
|
101
|
+
export async function verifyMandateAuthorization(c) {
|
|
102
|
+
const h = c.authorized_by?.mandate_sha256;
|
|
103
|
+
if (!h)
|
|
104
|
+
return "not_covered";
|
|
105
|
+
return (await mandateSha256(c.mandate)) === h ? "signed" : "mismatch";
|
|
106
|
+
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/person.d.ts
CHANGED
|
@@ -26,11 +26,12 @@ export declare function personKeys(json: string | undefined): {
|
|
|
26
26
|
} | null;
|
|
27
27
|
/** Consent the person signs with the e-CPF; part of the challenge text, so the signature records it. */
|
|
28
28
|
export declare const PERSON_CONSENT = "I authorize this issuer to keep a keyed hash of my CPF while this credential is valid and to answer, to verifiers who pay for each check, only whether a CPF they already have matches. My name and CPF are never stored in clear, published or revealed. / Autorizo este emissor a guardar um hash com chave do meu CPF enquanto esta credencial valer e a responder, a verificadores que pagam por consulta, apenas se um CPF que eles ja tem confere. Meu nome e meu CPF nunca sao guardados em claro, publicados ou revelados.";
|
|
29
|
-
/** The text a person and the agent sign (no CPF: it comes from the e-CPF certificate). */
|
|
29
|
+
/** The text a person and the agent sign (no CPF: it comes from the e-CPF certificate); with the mandate's SHA-256 (D-33). */
|
|
30
30
|
export declare function personChallengeText(p: {
|
|
31
31
|
issuer: string;
|
|
32
32
|
challenge_id: string;
|
|
33
33
|
agent: AgentKey;
|
|
34
|
+
mandate_sha256: string;
|
|
34
35
|
expires_at: string;
|
|
35
36
|
}): string;
|
|
36
37
|
/** The text a person signs with the e-CPF to see their own agents (accepted for 10 minutes after `at`). */
|
|
@@ -38,5 +39,6 @@ export declare const personAgentsText: (at?: Date) => string;
|
|
|
38
39
|
export declare function newPersonChallenge(p: {
|
|
39
40
|
issuer: string;
|
|
40
41
|
agent: AgentKey;
|
|
42
|
+
mandate_sha256: string;
|
|
41
43
|
now?: Date;
|
|
42
44
|
}): Challenge;
|
package/dist/person.js
CHANGED
|
@@ -66,8 +66,10 @@ export function personKeys(json) {
|
|
|
66
66
|
}
|
|
67
67
|
/** Consent the person signs with the e-CPF; part of the challenge text, so the signature records it. */
|
|
68
68
|
export const PERSON_CONSENT = "I authorize this issuer to keep a keyed hash of my CPF while this credential is valid and to answer, to verifiers who pay for each check, only whether a CPF they already have matches. My name and CPF are never stored in clear, published or revealed. / Autorizo este emissor a guardar um hash com chave do meu CPF enquanto esta credencial valer e a responder, a verificadores que pagam por consulta, apenas se um CPF que eles ja tem confere. Meu nome e meu CPF nunca sao guardados em claro, publicados ou revelados.";
|
|
69
|
-
/** The text a person and the agent sign (no CPF: it comes from the e-CPF certificate). */
|
|
69
|
+
/** The text a person and the agent sign (no CPF: it comes from the e-CPF certificate); with the mandate's SHA-256 (D-33). */
|
|
70
70
|
export function personChallengeText(p) {
|
|
71
|
+
if (typeof p.mandate_sha256 !== "string" || !/^[0-9a-f]{64}$/.test(p.mandate_sha256))
|
|
72
|
+
throw new KyaError("INVALID_INPUT", "mandate_sha256 must be 64 lowercase hex characters (see mandateSha256)");
|
|
71
73
|
return [
|
|
72
74
|
"KYA Brasil: prove this agent key acts for me, a natural person",
|
|
73
75
|
`issuer: ${p.issuer}`,
|
|
@@ -75,6 +77,7 @@ export function personChallengeText(p) {
|
|
|
75
77
|
`agent: ${p.agent.kind}:${p.agent.id}`,
|
|
76
78
|
"principal: person (e-CPF)",
|
|
77
79
|
`consent: ${PERSON_CONSENT}`,
|
|
80
|
+
`mandate-sha256: ${p.mandate_sha256}`,
|
|
78
81
|
`expires: ${p.expires_at}`
|
|
79
82
|
].join("\n");
|
|
80
83
|
}
|
|
@@ -84,5 +87,5 @@ export function newPersonChallenge(p) {
|
|
|
84
87
|
const now = p.now ?? new Date();
|
|
85
88
|
const challenge_id = crypto.randomUUID();
|
|
86
89
|
const expires_at = new Date(now.getTime() + CHALLENGE_TTL_SECONDS * 1000).toISOString();
|
|
87
|
-
return { challenge_id, expires_at, text: personChallengeText({ issuer: p.issuer, challenge_id, agent: p.agent, expires_at }) };
|
|
90
|
+
return { challenge_id, expires_at, text: personChallengeText({ issuer: p.issuer, challenge_id, agent: p.agent, mandate_sha256: p.mandate_sha256, expires_at }) };
|
|
88
91
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type Credential } from "./types.js";
|
|
2
|
+
/** What the agent presents: the exact challenge text the principal signed, and the detached CMS (base64 DER). */
|
|
3
|
+
export interface PrincipalProof {
|
|
4
|
+
text: string;
|
|
5
|
+
cms: string;
|
|
6
|
+
}
|
|
7
|
+
export interface PrincipalProofCheck {
|
|
8
|
+
rule: "signature" | "certificate" | "agent" | "principal" | "issuer" | "mandate";
|
|
9
|
+
ok: boolean;
|
|
10
|
+
detail: string;
|
|
11
|
+
}
|
|
12
|
+
export interface PrincipalProofResult {
|
|
13
|
+
ok: boolean;
|
|
14
|
+
checks: PrincipalProofCheck[];
|
|
15
|
+
/** For the caller's revocation check of the signing certificate; null when the signature did not verify. */
|
|
16
|
+
revocation: {
|
|
17
|
+
serial_number: string;
|
|
18
|
+
issuer_certificate: string | null;
|
|
19
|
+
crl_urls: string[];
|
|
20
|
+
} | null;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Checks, against the credential and without the issuer, that the principal's own signature authorizes this
|
|
24
|
+
* agent with this mandate: the CMS verifies over the text up to the ICP-Brasil anchors; the certificate is the
|
|
25
|
+
* one the credential records; the text names the credential's agent, principal and issuer; and the mandate hash
|
|
26
|
+
* in the text equals the hash of the credential's mandate. `anchors`, `now` and the revocation check are the
|
|
27
|
+
* caller's. Never throws on a bad proof: every check is reported.
|
|
28
|
+
*/
|
|
29
|
+
export declare function verifyPrincipalProof(p: {
|
|
30
|
+
credential: Pick<Credential, "agent" | "principal" | "authorized_by" | "mandate" | "issuer">;
|
|
31
|
+
proof: PrincipalProof;
|
|
32
|
+
anchors: string[];
|
|
33
|
+
now: Date;
|
|
34
|
+
}): Promise<PrincipalProofResult>;
|
|
35
|
+
/** Header value for a request: base64url of the JSON { text, cms }. */
|
|
36
|
+
export declare function encodePrincipalProofHeader(proof: PrincipalProof): string;
|
|
37
|
+
/** Parses the header value; throws INVALID_INPUT when it is not a proof. */
|
|
38
|
+
export declare function decodePrincipalProofHeader(value: string): PrincipalProof;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verification without trusting the issuer (D-34, SPEC section 19).
|
|
3
|
+
*
|
|
4
|
+
* Since D-33 the principal signs, with the e-CNPJ or e-CPF, a challenge that names the agent and carries the
|
|
5
|
+
* mandate's SHA-256, and keeps the signed text and the detached CMS (.p7s). When the principal chooses to hand
|
|
6
|
+
* them to its agent, the agent can present them with a request, and any verifier can check by itself, with the
|
|
7
|
+
* ICP-Brasil trust anchors, that this principal authorized this agent with these powers: the issuer's key is
|
|
8
|
+
* then needed for nothing but the credential's status.
|
|
9
|
+
*
|
|
10
|
+
* Pure functions, no network. The certificate's revocation is the caller's: this module returns what is needed
|
|
11
|
+
* to fetch and check the CRL (checkRevocation, evidenceFromRevocation), never personal data. The CMS carries the
|
|
12
|
+
* signer's certificate, so the principal decides when to share it, and the verifier treats it as personal data.
|
|
13
|
+
*/
|
|
14
|
+
import { base64url, mandateSha256OfChallenge } from "./challenge.js";
|
|
15
|
+
import { mandateSha256 } from "./credential.js";
|
|
16
|
+
import { verifyIcpPersonSignature, verifyIcpSignature } from "./icp-brasil.js";
|
|
17
|
+
import { KyaError, isPersonPrincipal } from "./types.js";
|
|
18
|
+
const lineOf = (text, key) => {
|
|
19
|
+
const lines = text.split("\n").filter((l) => l.startsWith(`${key}: `));
|
|
20
|
+
return lines.length === 1 ? lines[0].slice(key.length + 2) : null;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Checks, against the credential and without the issuer, that the principal's own signature authorizes this
|
|
24
|
+
* agent with this mandate: the CMS verifies over the text up to the ICP-Brasil anchors; the certificate is the
|
|
25
|
+
* one the credential records; the text names the credential's agent, principal and issuer; and the mandate hash
|
|
26
|
+
* in the text equals the hash of the credential's mandate. `anchors`, `now` and the revocation check are the
|
|
27
|
+
* caller's. Never throws on a bad proof: every check is reported.
|
|
28
|
+
*/
|
|
29
|
+
export async function verifyPrincipalProof(p) {
|
|
30
|
+
const { credential: c, proof } = p;
|
|
31
|
+
const checks = [];
|
|
32
|
+
const push = (rule, ok, detail) => checks.push({ rule, ok, detail });
|
|
33
|
+
if (!proof || typeof proof.text !== "string" || typeof proof.cms !== "string") {
|
|
34
|
+
push("signature", false, "proof must carry the signed text and the CMS");
|
|
35
|
+
return { ok: false, checks, revocation: null };
|
|
36
|
+
}
|
|
37
|
+
if (!(p.now instanceof Date) || Number.isNaN(p.now.getTime())) {
|
|
38
|
+
push("signature", false, "the verifier's clock is not a valid date");
|
|
39
|
+
return { ok: false, checks, revocation: null };
|
|
40
|
+
}
|
|
41
|
+
const person = isPersonPrincipal(c.principal);
|
|
42
|
+
let certificateSha256 = null;
|
|
43
|
+
let revocation = null;
|
|
44
|
+
try {
|
|
45
|
+
const icp = person
|
|
46
|
+
? await verifyIcpPersonSignature({ cmsBase64: proof.cms, challenge: proof.text, anchors: p.anchors, now: p.now })
|
|
47
|
+
: await verifyIcpSignature({ cmsBase64: proof.cms, challenge: proof.text, expectedCnpj: c.principal.cnpj, anchors: p.anchors, now: p.now });
|
|
48
|
+
certificateSha256 = icp.certificate_sha256;
|
|
49
|
+
revocation = { serial_number: icp.serial_number, issuer_certificate: icp.issuer_certificate, crl_urls: icp.crl_urls };
|
|
50
|
+
push("signature", true, `${person ? "e-CPF" : "e-CNPJ"} signature over the text verifies up to the ICP-Brasil anchors`);
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
push("signature", false, e instanceof KyaError ? e.message : `signature does not verify: ${e.message}`);
|
|
54
|
+
}
|
|
55
|
+
if (certificateSha256 !== null) {
|
|
56
|
+
const same = certificateSha256 === c.authorized_by.certificate_sha256;
|
|
57
|
+
push("certificate", same, same ? "signed with the certificate the credential records" : "signed with another certificate than the one the credential records");
|
|
58
|
+
}
|
|
59
|
+
const agent = lineOf(proof.text, "agent");
|
|
60
|
+
const expectedAgent = `${c.agent.kind}:${c.agent.id}`;
|
|
61
|
+
push("agent", agent === expectedAgent, agent === expectedAgent ? `text names the credential's agent ${expectedAgent}` : `text names agent ${agent ?? "(none)"}, the credential ${expectedAgent}`);
|
|
62
|
+
if (person) {
|
|
63
|
+
const ok = lineOf(proof.text, "principal") === "person (e-CPF)";
|
|
64
|
+
push("principal", ok, ok ? "text is a natural person's authorization" : "text is not a natural person's authorization");
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
const cnpj = lineOf(proof.text, "cnpj");
|
|
68
|
+
const expected = c.principal.cnpj;
|
|
69
|
+
push("principal", cnpj === expected, cnpj === expected ? `text names CNPJ ${expected}` : `text names CNPJ ${cnpj ?? "(none)"}, the credential ${expected}`);
|
|
70
|
+
}
|
|
71
|
+
const issuer = lineOf(proof.text, "issuer");
|
|
72
|
+
push("issuer", issuer === c.issuer, issuer === c.issuer ? `text names issuer ${c.issuer}` : `text names issuer ${issuer ?? "(none)"}, the credential ${c.issuer}`);
|
|
73
|
+
const signed = mandateSha256OfChallenge(proof.text);
|
|
74
|
+
const actual = await mandateSha256(c.mandate);
|
|
75
|
+
const recorded = c.authorized_by.mandate_sha256;
|
|
76
|
+
const mandateOk = signed !== null && signed === actual && (!recorded || recorded === signed);
|
|
77
|
+
push("mandate", mandateOk, signed === null ? "text carries no mandate hash (signed before D-33)" : mandateOk ? "text commits to the credential's mandate" : "text commits to another mandate than the credential's");
|
|
78
|
+
return { ok: checks.every((x) => x.ok), checks, revocation };
|
|
79
|
+
}
|
|
80
|
+
/** Header value for a request: base64url of the JSON { text, cms }. */
|
|
81
|
+
export function encodePrincipalProofHeader(proof) {
|
|
82
|
+
return base64url.encode(new TextEncoder().encode(JSON.stringify({ text: proof.text, cms: proof.cms })));
|
|
83
|
+
}
|
|
84
|
+
/** Parses the header value; throws INVALID_INPUT when it is not a proof. */
|
|
85
|
+
export function decodePrincipalProofHeader(value) {
|
|
86
|
+
let parsed;
|
|
87
|
+
try {
|
|
88
|
+
parsed = JSON.parse(new TextDecoder().decode(base64url.decode(value.trim())));
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
throw new KyaError("INVALID_INPUT", "KYA-Principal-Proof is not base64url JSON");
|
|
92
|
+
}
|
|
93
|
+
const o = parsed;
|
|
94
|
+
if (!o || typeof o.text !== "string" || typeof o.cms !== "string")
|
|
95
|
+
throw new KyaError("INVALID_INPUT", "KYA-Principal-Proof must carry text and cms");
|
|
96
|
+
return { text: o.text, cms: o.cms };
|
|
97
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -97,13 +97,18 @@ export interface Credential {
|
|
|
97
97
|
agent: AgentKey;
|
|
98
98
|
/** A company (e-CNPJ) or, since D-29, a natural person (e-CPF) with no personal data: tell them apart by `kind`. */
|
|
99
99
|
principal: CompanyPrincipal | PersonPrincipal;
|
|
100
|
-
/**
|
|
100
|
+
/**
|
|
101
|
+
* How the principal authorized it: hash of the ICP-Brasil certificate that signed the challenge. No subject for
|
|
102
|
+
* persons. `mandate_sha256` (D-33) is the mandate's SHA-256 that the signed challenge carried: when it equals
|
|
103
|
+
* mandateSha256(mandate), the certificate signature covers these powers (see verifyMandateAuthorization).
|
|
104
|
+
*/
|
|
101
105
|
authorized_by: {
|
|
102
106
|
method: "icp-brasil-e-cnpj" | "icp-brasil-e-cpf";
|
|
103
107
|
certificate_sha256: string;
|
|
104
108
|
certificate_subject?: string;
|
|
105
109
|
issuer: string;
|
|
106
110
|
signed_at: string;
|
|
111
|
+
mandate_sha256?: string;
|
|
107
112
|
};
|
|
108
113
|
mandate: Mandate;
|
|
109
114
|
/** Informative since D-15: the issuance decision uses only principal.registration_status and the certificate's revocation status. Always null from the reference issuer since D-23 (no KYB dependency); kept in /v1. Null when the issuer has no KYB source (D-17). */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kya-brasil/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "KYA Brasil as a library: verify and issue know-your-agent credentials for Brazilian companies without any hosted service. e-CNPJ (ICP-Brasil) CMS signature and chain validation, CRL revocation, CNPJ, agent key challenges (EVM, Ed25519 / Web Bot Auth, ERC-8004), mandate schema and evaluation (including payment instruments for card networks and acquirers), credential build, Ed25519 signing and verification, 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",
|