@kya-brasil/core 0.15.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
@@ -186,25 +186,28 @@ const ecnpj_signature = await signer.sign(challengeText);
186
186
  ```ts
187
187
  import {
188
188
  newChallenge, verifyAgentSignature, verifyIcpSignature, checkRevocation,
189
- validateMandate, buildCredential, signCredential, importPrivateKey, generateAttestationKey
189
+ validateMandate, mandateSha256, buildCredential, signCredential, importPrivateKey, generateAttestationKey
190
190
  } from "@kya-brasil/core";
191
191
 
192
192
  const key = await generateAttestationKey(); // once; keep privateKeyPkcs8 secret, publish publicJwk
193
- 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 });
194
196
  // The agent signs ch.text with its key; the company signs ch.text with its e-CNPJ (detached CMS).
195
197
  if (!(await verifyAgentSignature(agent, ch.text, agentSignature))) throw new Error("agent signature");
196
198
  const icp = await verifyIcpSignature({ cmsBase64: ecnpjSignature, challenge: ch.text, expectedCnpj: cnpj, anchors });
197
199
  // anchors: the ICP-Brasil roots as PEM strings (bundle in the repository: anchors/icp-brasil.pem).
198
200
  const crlDer = await fetchCrl(icp.crl_urls); // yours: HTTP + cache
199
- 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" });
200
202
  if (rev.revoked) throw new Error("certificate revoked");
201
203
 
202
204
  const credential = await signCredential(
203
205
  buildCredential({
204
206
  credential_id: crypto.randomUUID(), agent, cnpj,
205
207
  legal_name, registration_status, // from your registration source; "UNVERIFIED" if none answered
206
- certificate: { sha256: icp.certificate_sha256, subject: icp.certificate_subject, issuer: icp.issuer, signed_at: icp.signed_at },
207
- 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)
208
211
  issuer: "https://kya.acquirer.example"
209
212
  }),
210
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,
@@ -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.15.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",