@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 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 ch = newChallenge({ issuer: "https://kya.acquirer.example", agent, cnpj });
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: validateMandate(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
@@ -1,18 +1,32 @@
1
- import type { Credential, CredentialStatus, KybSnapshot, Mandate, AgentKey } from "./types.js";
2
- export declare const CREDENTIAL_TTL_DAYS = 90;
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;
@@ -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
- export const CREDENTIAL_TTL_DAYS = 90;
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 ttl = new Date(now.getTime() + CREDENTIAL_TTL_DAYS * 86400000);
13
- const mandateEnd = p.mandate.valid_until ? new Date(p.mandate.valid_until) : null;
14
- const expires = mandateEnd && mandateEnd < ttl ? mandateEnd : ttl;
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 ttl = new Date(now.getTime() + CREDENTIAL_TTL_DAYS * 86400000);
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
@@ -12,3 +12,4 @@ export * from "./cloud-sign.js";
12
12
  export * from "./person.js";
13
13
  export * from "./delegation.js";
14
14
  export * from "./freshness.js";
15
+ export * from "./principal-proof.js";
package/dist/index.js CHANGED
@@ -12,3 +12,4 @@ export * from "./cloud-sign.js";
12
12
  export * from "./person.js";
13
13
  export * from "./delegation.js";
14
14
  export * from "./freshness.js";
15
+ export * from "./principal-proof.js";
@@ -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
+ }
@@ -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
- * What a CRL entry does to a credential the certificate already authorized (D-16):
51
- * - certificateHold: suspend (the AC may lift it; the review reinstates when the entry disappears);
52
- * - superseded / cessationOfOperation dated after the authorization: nothing, the company replaced or
53
- * retired the certificate without compromise and its signature stands (registration is checked apart);
54
- * - anything else (unspecified, keyCompromise, privilegeWithdrawn, ...): revoke.
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">, authorizedAt: string | null): "none" | "suspend" | "revoke";
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";
@@ -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 a credential the certificate already authorized (D-16):
79
- * - certificateHold: suspend (the AC may lift it; the review reinstates when the entry disappears);
80
- * - superseded / cessationOfOperation dated after the authorization: nothing, the company replaced or
81
- * retired the certificate without compromise and its signature stands (registration is checked apart);
82
- * - anything else (unspecified, keyCompromise, privilegeWithdrawn, ...): revoke.
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
- if (r.reason === "certificateHold")
89
- return "suspend";
90
- const benign = r.reason === "superseded" || r.reason === "cessationOfOperation";
91
- if (benign && authorizedAt && r.revoked_at && Date.parse(r.revoked_at) > Date.parse(authorizedAt))
92
- return "none";
93
- return "revoke";
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.14.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",