@kya-brasil/core 0.14.0 → 0.17.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 +27 -5
- package/dist/credential.d.ts +20 -2
- package/dist/credential.js +21 -7
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/principal-proof.d.ts +38 -0
- package/dist/principal-proof.js +97 -0
- package/dist/revocation.d.ts +29 -6
- package/dist/revocation.js +37 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -46,6 +46,25 @@ const verdict = checkAction(credential.mandate, {
|
|
|
46
46
|
// verdict.allowed, verdict.checks (every rule evaluated, with its reason), verdict.not_evaluated (extensions)
|
|
47
47
|
```
|
|
48
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
|
+
|
|
49
68
|
## Delegation chains (sub-agents)
|
|
50
69
|
|
|
51
70
|
A mandate with `delegation: { max_depth, max_ttl_seconds }` lets the agent hand a narrower mandate to another key
|
|
@@ -167,25 +186,28 @@ const ecnpj_signature = await signer.sign(challengeText);
|
|
|
167
186
|
```ts
|
|
168
187
|
import {
|
|
169
188
|
newChallenge, verifyAgentSignature, verifyIcpSignature, checkRevocation,
|
|
170
|
-
validateMandate, buildCredential, signCredential, importPrivateKey, generateAttestationKey
|
|
189
|
+
validateMandate, mandateSha256, buildCredential, signCredential, importPrivateKey, generateAttestationKey
|
|
171
190
|
} from "@kya-brasil/core";
|
|
172
191
|
|
|
173
192
|
const key = await generateAttestationKey(); // once; keep privateKeyPkcs8 secret, publish publicJwk
|
|
174
|
-
const
|
|
193
|
+
const m = validateMandate(mandate);
|
|
194
|
+
const mandate_sha256 = await mandateSha256(m); // the e-CNPJ signs this hash with the challenge (D-33)
|
|
195
|
+
const ch = newChallenge({ issuer: "https://kya.acquirer.example", agent, cnpj, mandate_sha256 });
|
|
175
196
|
// The agent signs ch.text with its key; the company signs ch.text with its e-CNPJ (detached CMS).
|
|
176
197
|
if (!(await verifyAgentSignature(agent, ch.text, agentSignature))) throw new Error("agent signature");
|
|
177
198
|
const icp = await verifyIcpSignature({ cmsBase64: ecnpjSignature, challenge: ch.text, expectedCnpj: cnpj, anchors });
|
|
178
199
|
// anchors: the ICP-Brasil roots as PEM strings (bundle in the repository: anchors/icp-brasil.pem).
|
|
179
200
|
const crlDer = await fetchCrl(icp.crl_urls); // yours: HTTP + cache
|
|
180
|
-
const rev = await checkRevocation({ crlDer, serial: icp.serial_number, issuerCertificate: icp.issuer_certificate
|
|
201
|
+
const rev = await checkRevocation({ crlDer, serial: icp.serial_number, issuerCertificate: icp.issuer_certificate!, pastNextUpdate: "refuse" });
|
|
181
202
|
if (rev.revoked) throw new Error("certificate revoked");
|
|
182
203
|
|
|
183
204
|
const credential = await signCredential(
|
|
184
205
|
buildCredential({
|
|
185
206
|
credential_id: crypto.randomUUID(), agent, cnpj,
|
|
186
207
|
legal_name, registration_status, // from your registration source; "UNVERIFIED" if none answered
|
|
187
|
-
certificate: { sha256: icp.certificate_sha256, subject: icp.certificate_subject, issuer: icp.issuer, signed_at: icp.signed_at },
|
|
188
|
-
mandate:
|
|
208
|
+
certificate: { sha256: icp.certificate_sha256, subject: icp.certificate_subject, issuer: icp.issuer, signed_at: icp.signed_at, not_after: icp.not_after },
|
|
209
|
+
mandate: m, mandate_sha256,
|
|
210
|
+
ttl_seconds: 30 * 86400, // your risk decision; capped by the certificate and the mandate (D-35)
|
|
189
211
|
issuer: "https://kya.acquirer.example"
|
|
190
212
|
}),
|
|
191
213
|
await importPrivateKey(key.privateKeyPkcs8), key.keyId
|
package/dist/credential.d.ts
CHANGED
|
@@ -1,18 +1,32 @@
|
|
|
1
|
-
import type
|
|
2
|
-
|
|
1
|
+
import { type Credential, type CredentialStatus, type KybSnapshot, type Mandate, type AgentKey } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* D-35: when a credential expires. The lifetime is the caller's (`ttl_seconds`, no default: it is a risk decision),
|
|
4
|
+
* and the credential never outlives the mandate nor the certificate that signed it, so a proof checked against
|
|
5
|
+
* ICP-Brasil (D-34) never fails while the credential still reads as active.
|
|
6
|
+
*/
|
|
7
|
+
export declare function credentialExpiry(p: {
|
|
8
|
+
now: Date;
|
|
9
|
+
ttl_seconds: number;
|
|
10
|
+
mandate: Pick<Mandate, "valid_until">;
|
|
11
|
+
certificate_not_after: string;
|
|
12
|
+
}): Date;
|
|
3
13
|
export declare function buildCredential(p: {
|
|
4
14
|
credential_id: string;
|
|
5
15
|
agent: AgentKey;
|
|
6
16
|
cnpj: string;
|
|
7
17
|
legal_name: string;
|
|
8
18
|
registration_status: string;
|
|
19
|
+
/** not_after: the signing certificate's end of validity (IcpProof.not_after); the credential never outlives it. */
|
|
9
20
|
certificate: {
|
|
10
21
|
sha256: string;
|
|
11
22
|
subject: string;
|
|
12
23
|
issuer: string;
|
|
13
24
|
signed_at: string;
|
|
25
|
+
not_after: string;
|
|
14
26
|
};
|
|
15
27
|
mandate: Mandate;
|
|
28
|
+
/** D-35: the credential lifetime in seconds. Required, no default: how long a credential lives is the caller's risk. */
|
|
29
|
+
ttl_seconds: number;
|
|
16
30
|
/** D-33: the mandate SHA-256 the signed challenge carried (mandateSha256OfChallenge). */
|
|
17
31
|
mandate_sha256?: string;
|
|
18
32
|
/** /v1 compatibility only; leave out (null). */
|
|
@@ -26,12 +40,16 @@ export declare function buildCredential(p: {
|
|
|
26
40
|
export declare function buildPersonCredential(p: {
|
|
27
41
|
credential_id: string;
|
|
28
42
|
agent: AgentKey;
|
|
43
|
+
/** not_after: the signing certificate's end of validity (IcpPersonProof.not_after); the credential never outlives it. */
|
|
29
44
|
certificate: {
|
|
30
45
|
sha256: string;
|
|
31
46
|
issuer: string;
|
|
32
47
|
signed_at: string;
|
|
48
|
+
not_after: string;
|
|
33
49
|
};
|
|
34
50
|
mandate: Mandate;
|
|
51
|
+
/** D-35: the credential lifetime in seconds. Required, no default. */
|
|
52
|
+
ttl_seconds: number;
|
|
35
53
|
/** D-33: the mandate SHA-256 the signed challenge carried (mandateSha256OfChallenge). */
|
|
36
54
|
mandate_sha256?: string;
|
|
37
55
|
issuer: string;
|
package/dist/credential.js
CHANGED
|
@@ -5,13 +5,29 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { attestedPayload, canonicalize, signAttestation, verifyAttestation } from "./attestation.js";
|
|
7
7
|
import { formatCnpj, normalizeCnpj } from "./cnpj.js";
|
|
8
|
-
|
|
8
|
+
import { KyaError } from "./types.js";
|
|
9
|
+
/**
|
|
10
|
+
* D-35: when a credential expires. The lifetime is the caller's (`ttl_seconds`, no default: it is a risk decision),
|
|
11
|
+
* and the credential never outlives the mandate nor the certificate that signed it, so a proof checked against
|
|
12
|
+
* ICP-Brasil (D-34) never fails while the credential still reads as active.
|
|
13
|
+
*/
|
|
14
|
+
export function credentialExpiry(p) {
|
|
15
|
+
if (typeof p.ttl_seconds !== "number" || !Number.isFinite(p.ttl_seconds) || p.ttl_seconds <= 0)
|
|
16
|
+
throw new KyaError("INVALID_INPUT", "ttl_seconds must be a positive number of seconds (the credential lifetime is the caller's decision)");
|
|
17
|
+
const notAfter = new Date(p.certificate_not_after);
|
|
18
|
+
if (Number.isNaN(notAfter.getTime()))
|
|
19
|
+
throw new KyaError("INVALID_INPUT", "certificate.not_after must be an ISO 8601 date");
|
|
20
|
+
if (notAfter <= p.now)
|
|
21
|
+
throw new KyaError("CERTIFICATE_INVALID", `certificate expired on ${notAfter.toISOString()}`);
|
|
22
|
+
const candidates = [new Date(p.now.getTime() + p.ttl_seconds * 1000), notAfter];
|
|
23
|
+
if (p.mandate.valid_until)
|
|
24
|
+
candidates.push(new Date(p.mandate.valid_until));
|
|
25
|
+
return new Date(Math.min(...candidates.map((d) => d.getTime())));
|
|
26
|
+
}
|
|
9
27
|
export function buildCredential(p) {
|
|
10
28
|
const now = p.now ?? new Date();
|
|
11
29
|
const cnpj = normalizeCnpj(p.cnpj);
|
|
12
|
-
const
|
|
13
|
-
const mandateEnd = p.mandate.valid_until ? new Date(p.mandate.valid_until) : null;
|
|
14
|
-
const expires = mandateEnd && mandateEnd < ttl ? mandateEnd : ttl;
|
|
30
|
+
const expires = credentialExpiry({ now, ttl_seconds: p.ttl_seconds, mandate: p.mandate, certificate_not_after: p.certificate.not_after });
|
|
15
31
|
return {
|
|
16
32
|
credential_id: p.credential_id,
|
|
17
33
|
version: 1,
|
|
@@ -32,9 +48,7 @@ export function buildCredential(p) {
|
|
|
32
48
|
/** D-29: credential of a natural person. Nothing personal: no name, no CPF, no certificate subject. */
|
|
33
49
|
export function buildPersonCredential(p) {
|
|
34
50
|
const now = p.now ?? new Date();
|
|
35
|
-
const
|
|
36
|
-
const mandateEnd = p.mandate.valid_until ? new Date(p.mandate.valid_until) : null;
|
|
37
|
-
const expires = mandateEnd && mandateEnd < ttl ? mandateEnd : ttl;
|
|
51
|
+
const expires = credentialExpiry({ now, ttl_seconds: p.ttl_seconds, mandate: p.mandate, certificate_not_after: p.certificate.not_after });
|
|
38
52
|
return {
|
|
39
53
|
credential_id: p.credential_id,
|
|
40
54
|
version: 1,
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -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/revocation.d.ts
CHANGED
|
@@ -15,6 +15,8 @@ export interface RevocationStatus {
|
|
|
15
15
|
revoked: boolean;
|
|
16
16
|
revoked_at: string | null;
|
|
17
17
|
reason: string | null;
|
|
18
|
+
/** RFC 5280 invalidityDate: when the key is known or suspected to have been compromised, if the AC says so. */
|
|
19
|
+
invalidity_date?: string | null;
|
|
18
20
|
crl_this_update: string;
|
|
19
21
|
crl_next_update: string | null;
|
|
20
22
|
}
|
|
@@ -46,12 +48,33 @@ export declare function checkRevocation(p: {
|
|
|
46
48
|
pastNextUpdate: "refuse" | "accept";
|
|
47
49
|
now?: Date;
|
|
48
50
|
}): Promise<RevocationStatus>;
|
|
51
|
+
/** RFC 5280 CRL reason codes (section 5.3.1), by name. */
|
|
52
|
+
export declare const CRL_REASONS: readonly ["unspecified", "keyCompromise", "cACompromise", "affiliationChanged", "superseded", "cessationOfOperation", "certificateHold", "removeFromCRL", "privilegeWithdrawn", "aACompromise"];
|
|
53
|
+
export type CrlReason = (typeof CRL_REASONS)[number];
|
|
49
54
|
/**
|
|
50
|
-
*
|
|
51
|
-
* -
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* -
|
|
55
|
+
* D-36: what a revoked certificate does to what it already authorized, decided by whoever relies on it.
|
|
56
|
+
* - `by_reason`: the effect of each CRL reason the caller has an opinion on: "revoke" (the authorization ends),
|
|
57
|
+
* "suspend" (it stops while the entry stays, e.g. certificateHold) or "keep" (it stands: the certificate was
|
|
58
|
+
* replaced or retired without compromise);
|
|
59
|
+
* - `other`: the effect of any reason not listed, including an entry without a reason. Never "keep": an
|
|
60
|
+
* unexplained revocation is not ignored.
|
|
61
|
+
* No default: which reasons end a mandate is a legal and risk decision (Código Civil art. 682; SPEC section 11).
|
|
62
|
+
*/
|
|
63
|
+
export interface CertificateRevocationPolicy {
|
|
64
|
+
by_reason: Partial<Record<CrlReason, "revoke" | "suspend" | "keep">>;
|
|
65
|
+
other: "revoke" | "suspend";
|
|
66
|
+
}
|
|
67
|
+
/** Throws INVALID_INPUT unless `p` is a complete CertificateRevocationPolicy. */
|
|
68
|
+
export declare function certificateRevocationPolicy(p: CertificateRevocationPolicy): CertificateRevocationPolicy;
|
|
69
|
+
/**
|
|
70
|
+
* What a CRL entry does to an authorization the certificate already signed (D-16, D-36), under the caller's policy.
|
|
71
|
+
* Two rules hold whatever the policy says, because they are about the signature, not about the mandate:
|
|
72
|
+
* - "keep" applies only when the revocation, and the suspected compromise (invalidityDate) when the AC gives
|
|
73
|
+
* one, come after the authorization; otherwise the authorization was signed with a certificate already
|
|
74
|
+
* revoked or already compromised, and it is revoked (MP 2.200-2 art. 10 presumes only a valid certificate);
|
|
75
|
+
* - without the authorization's date there is nothing to compare: "keep" becomes "revoke".
|
|
55
76
|
* At issuance any entry refuses: a new authorization must be signed with a certificate in force.
|
|
56
77
|
*/
|
|
57
|
-
export declare function revocationEffect(r: Pick<RevocationStatus, "revoked" | "revoked_at" | "reason"
|
|
78
|
+
export declare function revocationEffect(r: Pick<RevocationStatus, "revoked" | "revoked_at" | "reason"> & {
|
|
79
|
+
invalidity_date?: string | null;
|
|
80
|
+
}, authorizedAt: string | null, policy: CertificateRevocationPolicy): "none" | "suspend" | "revoke";
|
package/dist/revocation.js
CHANGED
|
@@ -64,6 +64,7 @@ export async function verifyCrl(p) {
|
|
|
64
64
|
revoked: entry !== null,
|
|
65
65
|
revoked_at: entry ? entry.revocationDate.toISOString() : null,
|
|
66
66
|
reason: entry && entry.reason !== undefined ? (X509CrlReason[entry.reason] ?? String(entry.reason)) : null,
|
|
67
|
+
invalidity_date: entry?.invalidity ? entry.invalidity.toISOString() : null,
|
|
67
68
|
crl_this_update: this_update,
|
|
68
69
|
crl_next_update: next_update
|
|
69
70
|
};
|
|
@@ -74,21 +75,45 @@ export async function verifyCrl(p) {
|
|
|
74
75
|
export async function checkRevocation(p) {
|
|
75
76
|
return (await verifyCrl(p)).lookup(p.serial);
|
|
76
77
|
}
|
|
78
|
+
/** RFC 5280 CRL reason codes (section 5.3.1), by name. */
|
|
79
|
+
export const CRL_REASONS = ["unspecified", "keyCompromise", "cACompromise", "affiliationChanged", "superseded", "cessationOfOperation", "certificateHold", "removeFromCRL", "privilegeWithdrawn", "aACompromise"];
|
|
80
|
+
/** Throws INVALID_INPUT unless `p` is a complete CertificateRevocationPolicy. */
|
|
81
|
+
export function certificateRevocationPolicy(p) {
|
|
82
|
+
if (!p || typeof p !== "object")
|
|
83
|
+
throw new KyaError("INVALID_INPUT", "a certificate revocation policy is required: { by_reason, other }");
|
|
84
|
+
if (p.other !== "revoke" && p.other !== "suspend")
|
|
85
|
+
throw new KyaError("INVALID_INPUT", 'policy.other is required: "revoke" or "suspend" (an unexplained revocation is never ignored)');
|
|
86
|
+
if (!p.by_reason || typeof p.by_reason !== "object")
|
|
87
|
+
throw new KyaError("INVALID_INPUT", "policy.by_reason is required (it may be empty)");
|
|
88
|
+
for (const [k, v] of Object.entries(p.by_reason)) {
|
|
89
|
+
if (!CRL_REASONS.includes(k))
|
|
90
|
+
throw new KyaError("INVALID_INPUT", `policy.by_reason.${k} is not an RFC 5280 reason`);
|
|
91
|
+
if (v !== "revoke" && v !== "suspend" && v !== "keep")
|
|
92
|
+
throw new KyaError("INVALID_INPUT", `policy.by_reason.${k} must be "revoke", "suspend" or "keep"`);
|
|
93
|
+
}
|
|
94
|
+
return { by_reason: { ...p.by_reason }, other: p.other };
|
|
95
|
+
}
|
|
96
|
+
const isAfter = (a, b) => a !== null && Date.parse(a) > Date.parse(b);
|
|
77
97
|
/**
|
|
78
|
-
* What a CRL entry does to
|
|
79
|
-
*
|
|
80
|
-
* -
|
|
81
|
-
*
|
|
82
|
-
*
|
|
98
|
+
* What a CRL entry does to an authorization the certificate already signed (D-16, D-36), under the caller's policy.
|
|
99
|
+
* Two rules hold whatever the policy says, because they are about the signature, not about the mandate:
|
|
100
|
+
* - "keep" applies only when the revocation, and the suspected compromise (invalidityDate) when the AC gives
|
|
101
|
+
* one, come after the authorization; otherwise the authorization was signed with a certificate already
|
|
102
|
+
* revoked or already compromised, and it is revoked (MP 2.200-2 art. 10 presumes only a valid certificate);
|
|
103
|
+
* - without the authorization's date there is nothing to compare: "keep" becomes "revoke".
|
|
83
104
|
* At issuance any entry refuses: a new authorization must be signed with a certificate in force.
|
|
84
105
|
*/
|
|
85
|
-
export function revocationEffect(r, authorizedAt) {
|
|
106
|
+
export function revocationEffect(r, authorizedAt, policy) {
|
|
107
|
+
const pol = certificateRevocationPolicy(policy);
|
|
86
108
|
if (!r.revoked)
|
|
87
109
|
return "none";
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
110
|
+
const reason = r.reason && CRL_REASONS.includes(r.reason) ? r.reason : null;
|
|
111
|
+
const effect = (reason && pol.by_reason[reason]) ?? pol.other;
|
|
112
|
+
if (effect !== "keep")
|
|
113
|
+
return effect;
|
|
114
|
+
if (!authorizedAt || Number.isNaN(Date.parse(authorizedAt)))
|
|
115
|
+
return "revoke";
|
|
116
|
+
const revokedAfter = isAfter(r.revoked_at, authorizedAt);
|
|
117
|
+
const compromiseAfter = r.invalidity_date ? isAfter(r.invalidity_date, authorizedAt) : true;
|
|
118
|
+
return revokedAfter && compromiseAfter ? "none" : "revoke";
|
|
94
119
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kya-brasil/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.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",
|