@kya-brasil/core 0.8.0 → 0.10.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
@@ -87,6 +87,22 @@ const sig = await a3.sign(readSigningLink(link).text); // the text from a
87
87
  `openToken({ library, pin })` in `@kya-brasil/agent-tools` (and the `kya-sign --pkcs11` command) already wires
88
88
  `C_Sign` into `ecnpjSigner`.
89
89
 
90
+ ### Cloud certificates (PSC)
91
+
92
+ `CloudSignatureProvider` is the interface for an e-CNPJ held by an ICP-Brasil PSC: `authorizationUrl` (OAuth with
93
+ PKCE), `exchangeCode` returning a session with `certificates()` and `signHash(sha256)`. No PSC is implemented here
94
+ (each has its own contract); implement it for yours from
95
+ [examples/cloud-provider](https://github.com/celfons/kya-brasil/tree/main/examples/cloud-provider) and then:
96
+
97
+ ```ts
98
+ import { cloudSigner, pkcePair, newState } from "@kya-brasil/core";
99
+ const { verifier, challenge } = await pkcePair();
100
+ const url = await provider.authorizationUrl({ redirectUri, state: newState(), codeChallenge: challenge });
101
+ // … the person approves, your redirect URI gets ?code=…&state=…
102
+ const signer = await cloudSigner(await provider.exchangeCode({ code, redirectUri, codeVerifier: verifier }));
103
+ const ecnpj_signature = await signer.sign(challengeText);
104
+ ```
105
+
90
106
  ## Run your own issuer
91
107
 
92
108
  ```ts
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Cloud e-CNPJ (D-28): the certificate lives at an ICP-Brasil PSC (prestador de serviço de confiança) and signs
3
+ * after the holder approves, usually on the phone. Each PSC has its own contract, registration and API details,
4
+ * so this project ships the interface only: whoever has an agreement with a PSC implements CloudSignatureProvider
5
+ * for it and plugs it into the three entry points (this library, `kya-sign --cloud`, the /sign page of a
6
+ * self-hosted issuer). The KYA side (PKCE, single-use state, the CMS, what is signed) is done here.
7
+ *
8
+ * Typical PSC flow (OAuth 2.0 authorization code with PKCE): send the person to authorizationUrl, get a code at
9
+ * the redirect URI, exchange it for a session, read the certificate, sign hashes. Ask the PSC for the narrowest
10
+ * session it offers (one signature, minutes of lifetime).
11
+ */
12
+ import { type ECnpjSigner } from "./ecnpj-sign.js";
13
+ /** An authorized session at the PSC: the certificate and a way to sign one SHA-256 hash. */
14
+ export interface CloudSession {
15
+ /** Signer certificate first, then the chain if the PSC returns it (DER). */
16
+ certificates(): Promise<Uint8Array[]>;
17
+ /**
18
+ * RSA PKCS#1 v1.5 signature over a SHA-256 hash (32 bytes) computed by the caller: what PSC APIs usually
19
+ * call a "raw" or "PKCS1" signature of a hash. Return the signature bytes.
20
+ */
21
+ signHash(sha256: Uint8Array): Promise<Uint8Array>;
22
+ /** Revoke or forget the session at the PSC, when it supports it. */
23
+ close?(): Promise<void>;
24
+ }
25
+ /** One PSC. Implement it with your own registration (client id/secret, redirect URIs) and the PSC's API. */
26
+ export interface CloudSignatureProvider {
27
+ /** Short id used in URLs and on the command line, e.g. "my-psc". Lowercase letters, digits, "-". */
28
+ readonly id: string;
29
+ /** Name shown to the person, e.g. "BirdID", "VIDaaS". */
30
+ readonly name: string;
31
+ /**
32
+ * Where to send the person to log in and approve. Must carry `state` and the PKCE `codeChallenge` (S256) so the
33
+ * code comes back to `redirectUri` bound to this request. `loginHint` may carry a CNPJ/CPF when the PSC accepts it.
34
+ */
35
+ authorizationUrl(p: {
36
+ redirectUri: string;
37
+ state: string;
38
+ codeChallenge: string;
39
+ loginHint?: string;
40
+ }): string | Promise<string>;
41
+ /** Exchanges the code for a session (server or CLI side: this is where a client secret would be used). */
42
+ exchangeCode(p: {
43
+ code: string;
44
+ redirectUri: string;
45
+ codeVerifier: string;
46
+ }): Promise<CloudSession>;
47
+ }
48
+ /** PKCE (RFC 7636, S256): a random verifier kept by us and the challenge sent to the PSC. */
49
+ export declare function pkcePair(): Promise<{
50
+ verifier: string;
51
+ challenge: string;
52
+ }>;
53
+ /** A random, single-use OAuth state value. */
54
+ export declare const newState: () => string;
55
+ /** Valid provider ids: what can safely go in a URL path or a CLI flag. */
56
+ export declare const isProviderId: (id: string) => boolean;
57
+ /**
58
+ * An e-CNPJ signer over a PSC session: the CMS is built here, the PSC only signs the SHA-256 of the signed
59
+ * attributes. Same interface as openPfx (A1) and openToken (A3), so everything downstream is unchanged.
60
+ */
61
+ export declare function cloudSigner(session: CloudSession): Promise<ECnpjSigner>;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Cloud e-CNPJ (D-28): the certificate lives at an ICP-Brasil PSC (prestador de serviço de confiança) and signs
3
+ * after the holder approves, usually on the phone. Each PSC has its own contract, registration and API details,
4
+ * so this project ships the interface only: whoever has an agreement with a PSC implements CloudSignatureProvider
5
+ * for it and plugs it into the three entry points (this library, `kya-sign --cloud`, the /sign page of a
6
+ * self-hosted issuer). The KYA side (PKCE, single-use state, the CMS, what is signed) is done here.
7
+ *
8
+ * Typical PSC flow (OAuth 2.0 authorization code with PKCE): send the person to authorizationUrl, get a code at
9
+ * the redirect URI, exchange it for a session, read the certificate, sign hashes. Ask the PSC for the narrowest
10
+ * session it offers (one signature, minutes of lifetime).
11
+ */
12
+ import { ecnpjSigner } from "./ecnpj-sign.js";
13
+ import { KyaError } from "./types.js";
14
+ const toArrayBuffer = (u) => u.buffer.slice(u.byteOffset, u.byteOffset + u.byteLength);
15
+ const b64url = (u) => {
16
+ let s = "";
17
+ for (const b of u)
18
+ s += String.fromCharCode(b);
19
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
20
+ };
21
+ /** PKCE (RFC 7636, S256): a random verifier kept by us and the challenge sent to the PSC. */
22
+ export async function pkcePair() {
23
+ const verifier = b64url(crypto.getRandomValues(new Uint8Array(32)));
24
+ const challenge = b64url(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))));
25
+ return { verifier, challenge };
26
+ }
27
+ /** A random, single-use OAuth state value. */
28
+ export const newState = () => b64url(crypto.getRandomValues(new Uint8Array(24)));
29
+ /** Valid provider ids: what can safely go in a URL path or a CLI flag. */
30
+ export const isProviderId = (id) => /^[a-z0-9][a-z0-9-]{0,31}$/.test(id);
31
+ /**
32
+ * An e-CNPJ signer over a PSC session: the CMS is built here, the PSC only signs the SHA-256 of the signed
33
+ * attributes. Same interface as openPfx (A1) and openToken (A3), so everything downstream is unchanged.
34
+ */
35
+ export async function cloudSigner(session) {
36
+ const certificates = await session.certificates();
37
+ if (!certificates.length)
38
+ throw new KyaError("CERTIFICATE_INVALID", "the cloud provider returned no certificate");
39
+ return ecnpjSigner({
40
+ certificates,
41
+ sign: async (data) => {
42
+ const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", toArrayBuffer(data)));
43
+ const sig = await session.signHash(hash);
44
+ if (!sig?.length)
45
+ throw new KyaError("UPSTREAM_ERROR", "the cloud provider returned an empty signature");
46
+ return sig;
47
+ }
48
+ });
49
+ }
@@ -20,6 +20,19 @@ export declare function buildCredential(p: {
20
20
  status?: CredentialStatus;
21
21
  status_reason?: string | null;
22
22
  }): Credential;
23
+ /** D-29: credential of a natural person. Nothing personal: no name, no CPF, no certificate subject. */
24
+ export declare function buildPersonCredential(p: {
25
+ credential_id: string;
26
+ agent: AgentKey;
27
+ certificate: {
28
+ sha256: string;
29
+ issuer: string;
30
+ signed_at: string;
31
+ };
32
+ mandate: Mandate;
33
+ issuer: string;
34
+ now?: Date;
35
+ }): Credential;
23
36
  export declare function signCredential(c: Credential, privateKey: CryptoKey, keyId: string, now?: Date): Promise<Credential>;
24
37
  export declare function verifyCredentialSignature(c: Credential, publicJwk: JsonWebKey): Promise<{
25
38
  valid: boolean;
@@ -29,6 +29,29 @@ export function buildCredential(p) {
29
29
  revocation_url: `${p.issuer}/v1/agents/${p.credential_id}/status`
30
30
  };
31
31
  }
32
+ /** D-29: credential of a natural person. Nothing personal: no name, no CPF, no certificate subject. */
33
+ export function buildPersonCredential(p) {
34
+ 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;
38
+ return {
39
+ credential_id: p.credential_id,
40
+ version: 1,
41
+ agent: p.agent,
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 },
44
+ mandate: p.mandate,
45
+ kyb_snapshot: null,
46
+ status: "active",
47
+ status_reason: null,
48
+ issued_at: now.toISOString(),
49
+ expires_at: expires.toISOString(),
50
+ issuer: p.issuer,
51
+ verify_url: `${p.issuer}/v1/agents/${p.credential_id}`,
52
+ revocation_url: `${p.issuer}/v1/agents/${p.credential_id}/status`
53
+ };
54
+ }
32
55
  export async function signCredential(c, privateKey, keyId, now = new Date()) {
33
56
  const { attestation: _a, ...payload } = c;
34
57
  const attestation = await signAttestation(payload, privateKey, keyId, `${c.issuer}/v1/verify`, now);
@@ -29,10 +29,16 @@ export interface IcpProof {
29
29
  /** Base64 DER of the AC certificate that issued it (verifies the CRL), or null when not found. */
30
30
  issuer_certificate: string | null;
31
31
  }
32
- /** CNPJ carried by the certificate's ICP-Brasil otherName, or null when it is not a PJ certificate. */
33
32
  export declare function cnpjFromCertificate(cert: X509Certificate): string | null;
33
+ /** OID 2.16.76.1.3.1: data of the e-CPF holder, "ddmmaaaa" birth date followed by the 11-digit CPF (DOC-ICP-04). */
34
+ export declare const OID_ICP_PERSON = "2.16.76.1.3.1";
34
35
  /**
35
- * Verifies a CMS SignedData (base64 DER) over `challenge` and returns the proof. Throws KyaError
36
+ * CPF of an e-CPF (natural person) certificate: from the 2.16.76.1.3.1 otherName (positions 9 to 19), or the
37
+ * common name suffix ":<11 digits>" as a fallback. Null for an e-CNPJ or any other certificate.
38
+ */
39
+ export declare function cpfFromCertificate(cert: X509Certificate): string | null;
40
+ /**
41
+ * Verifies a CMS SignedData (base64 DER) over `challenge` by an e-CNPJ and returns the proof. Throws KyaError
36
42
  * CERTIFICATE_INVALID / CHALLENGE_FAILED / CNPJ_MISMATCH. `anchors` are PEM or DER (base64) roots.
37
43
  */
38
44
  export declare function verifyIcpSignature(p: {
@@ -43,3 +49,23 @@ export declare function verifyIcpSignature(p: {
43
49
  now?: Date;
44
50
  requireChain?: boolean;
45
51
  }): Promise<IcpProof>;
52
+ /** e-CPF proof (D-29). `cpf` is for the caller to key-hash at once and forget: never store or return it. No subject (name). */
53
+ export interface IcpPersonProof {
54
+ cpf: string;
55
+ certificate_sha256: string;
56
+ issuer: string;
57
+ not_before: string;
58
+ not_after: string;
59
+ signed_at: string;
60
+ serial_number: string;
61
+ crl_urls: string[];
62
+ issuer_certificate: string | null;
63
+ }
64
+ /** Verifies a CMS over `challenge` by a natural person's e-CPF (D-29). Refuses an e-CNPJ. */
65
+ export declare function verifyIcpPersonSignature(p: {
66
+ cmsBase64: string;
67
+ challenge: string;
68
+ anchors: string[];
69
+ now?: Date;
70
+ requireChain?: boolean;
71
+ }): Promise<IcpPersonProof>;
@@ -31,7 +31,8 @@ const unb64 = (s) => Uint8Array.from(atob(s.replace(/\s+/g, "")), (c) => c.charC
31
31
  const b64 = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf)));
32
32
  const hex = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
33
33
  /** CNPJ carried by the certificate's ICP-Brasil otherName, or null when it is not a PJ certificate. */
34
- export function cnpjFromCertificate(cert) {
34
+ /** Digits of an ICP-Brasil otherName in the Subject Alternative Name (OID 2.16.76.1.3.<n>), or null. */
35
+ function otherNameDigits(cert, oidSuffix) {
35
36
  const san = cert.extensions.find((e) => e.type === OID_SAN);
36
37
  if (!san)
37
38
  return null;
@@ -47,27 +48,44 @@ export function cnpjFromCertificate(cert) {
47
48
  const inner = block.valueBlock.value;
48
49
  const oid = inner[0];
49
50
  const oidStr = oid?.valueBlock?.toString?.() ?? "";
50
- if (!/2\.16\.76\.1\.3\.3$/.test(oidStr))
51
+ if (oidStr !== `2.16.76.1.3.${oidSuffix}`)
51
52
  continue;
52
53
  const explicit = inner[1];
53
54
  const valueBlock = explicit?.valueBlock?.value?.[0];
54
55
  if (!valueBlock)
55
56
  continue;
56
57
  const raw = "valueBlock" in valueBlock && "valueHexView" in valueBlock.valueBlock ? new TextDecoder().decode(valueBlock.valueBlock.valueHexView) : String(valueBlock.valueBlock.value);
57
- const digits = raw.replace(/\D/g, "");
58
- if (digits.length === 14)
59
- return digits;
58
+ return raw.replace(/\D/g, "");
60
59
  }
61
60
  return null;
62
61
  }
62
+ export function cnpjFromCertificate(cert) {
63
+ const digits = otherNameDigits(cert, "3");
64
+ return digits && digits.length === 14 ? digits : null;
65
+ }
66
+ /** OID 2.16.76.1.3.1: data of the e-CPF holder, "ddmmaaaa" birth date followed by the 11-digit CPF (DOC-ICP-04). */
67
+ export const OID_ICP_PERSON = "2.16.76.1.3.1";
68
+ /**
69
+ * CPF of an e-CPF (natural person) certificate: from the 2.16.76.1.3.1 otherName (positions 9 to 19), or the
70
+ * common name suffix ":<11 digits>" as a fallback. Null for an e-CNPJ or any other certificate.
71
+ */
72
+ export function cpfFromCertificate(cert) {
73
+ if (cnpjFromCertificate(cert))
74
+ return null;
75
+ const data = otherNameDigits(cert, "1");
76
+ if (data && data.length >= 19)
77
+ return data.slice(8, 19);
78
+ const cn = /CN=([^,]+)/.exec(cert.subject)?.[1] ?? "";
79
+ return /:(\d{11})\s*$/.exec(cn)?.[1] ?? null;
80
+ }
63
81
  async function sha256Hex(bytes) {
64
82
  return hex(await crypto.subtle.digest("SHA-256", bytes.slice().buffer));
65
83
  }
66
84
  /**
67
- * Verifies a CMS SignedData (base64 DER) over `challenge` and returns the proof. Throws KyaError
68
- * CERTIFICATE_INVALID / CHALLENGE_FAILED / CNPJ_MISMATCH. `anchors` are PEM or DER (base64) roots.
85
+ * Shared steps: parse the CMS, require attached content to equal the text, verify the signature, pick the signer
86
+ * certificate, check its dates, run `identify` (which throws for the wrong kind of certificate), then the chain.
69
87
  */
70
- export async function verifyIcpSignature(p) {
88
+ async function checkCms(p, identify) {
71
89
  ensureEngine();
72
90
  const now = p.now ?? new Date();
73
91
  let cms;
@@ -88,7 +106,7 @@ export async function verifyIcpSignature(p) {
88
106
  const trusted = p.anchors.map((a) => pkijs.Certificate.fromBER(unb64(a.replace(/-----[^-]+-----/g, ""))));
89
107
  const data = new TextEncoder().encode(p.challenge);
90
108
  // An attached CMS (the .p7s carries the signed text) is verified over its own content by pkijs, whatever
91
- // `data` says: it MUST carry exactly the challenge, or any document the company ever signed would pass.
109
+ // `data` says: it MUST carry exactly the challenge, or any document ever signed would pass.
92
110
  const embedded = signed.encapContentInfo.eContent;
93
111
  if (embedded) {
94
112
  const content = new Uint8Array(embedded.getValue());
@@ -104,7 +122,7 @@ export async function verifyIcpSignature(p) {
104
122
  throw new KyaError("CHALLENGE_FAILED", `signature verification failed: ${e.message}`);
105
123
  }
106
124
  if (!ok)
107
- throw new KyaError("CHALLENGE_FAILED", "e-CNPJ signature does not match the challenge");
125
+ throw new KyaError("CHALLENGE_FAILED", "the ICP-Brasil signature does not match the challenge");
108
126
  // The signer: match SignerInfo's issuerAndSerialNumber against the carried certificates.
109
127
  const si = signed.signerInfos[0];
110
128
  const sid = si.sid;
@@ -113,11 +131,7 @@ export async function verifyIcpSignature(p) {
113
131
  const x = new X509Certificate(der);
114
132
  if (x.notBefore > now || x.notAfter < now)
115
133
  throw new KyaError("CERTIFICATE_INVALID", `certificate valid from ${x.notBefore.toISOString()} to ${x.notAfter.toISOString()}`);
116
- const cnpj = cnpjFromCertificate(x);
117
- if (!cnpj)
118
- throw new KyaError("CERTIFICATE_INVALID", "certificate has no ICP-Brasil CNPJ (not an e-CNPJ)");
119
- if (cnpj !== normalizeCnpj(p.expectedCnpj))
120
- throw new KyaError("CNPJ_MISMATCH", `certificate belongs to CNPJ ${cnpj}, not ${normalizeCnpj(p.expectedCnpj)}`);
134
+ identify(x);
121
135
  if (p.requireChain !== false) {
122
136
  if (!trusted.length)
123
137
  throw new KyaError("CERTIFICATE_INVALID", "no ICP-Brasil trust anchors configured");
@@ -126,18 +140,60 @@ export async function verifyIcpSignature(p) {
126
140
  if (!chain.result)
127
141
  throw new KyaError("CERTIFICATE_INVALID", `certificate chain not trusted: ${chain.resultMessage}`);
128
142
  }
129
- // The issuing AC: carried in the CMS or among the anchors (the bundle includes the intermediate ACs).
130
- const issuerCert = [...certs, ...trusted].map((c) => new X509Certificate(c.toSchema().toBER(false))).find((c) => c.subject === x.issuer) ?? null;
143
+ return { x, der, certs, trusted, now };
144
+ }
145
+ /** The issuing AC: carried in the CMS or among the anchors (the bundle includes the intermediate ACs). */
146
+ function issuerCertificateOf(c) {
147
+ const issuerCert = [...c.certs, ...c.trusted].map((k) => new X509Certificate(k.toSchema().toBER(false))).find((k) => k.subject === c.x.issuer) ?? null;
148
+ return issuerCert ? b64(issuerCert.rawData) : null;
149
+ }
150
+ /**
151
+ * Verifies a CMS SignedData (base64 DER) over `challenge` by an e-CNPJ and returns the proof. Throws KyaError
152
+ * CERTIFICATE_INVALID / CHALLENGE_FAILED / CNPJ_MISMATCH. `anchors` are PEM or DER (base64) roots.
153
+ */
154
+ export async function verifyIcpSignature(p) {
155
+ let cnpj = "";
156
+ const c = await checkCms(p, (x) => {
157
+ const found = cnpjFromCertificate(x);
158
+ if (!found)
159
+ throw new KyaError("CERTIFICATE_INVALID", cpfFromCertificate(x) ? "this is a natural person's certificate (e-CPF); sign with the company's e-CNPJ" : "certificate has no ICP-Brasil CNPJ (not an e-CNPJ)");
160
+ if (found !== normalizeCnpj(p.expectedCnpj))
161
+ throw new KyaError("CNPJ_MISMATCH", `certificate belongs to CNPJ ${found}, not ${normalizeCnpj(p.expectedCnpj)}`);
162
+ cnpj = found;
163
+ });
131
164
  return {
132
165
  cnpj,
133
- certificate_sha256: await sha256Hex(new Uint8Array(der)),
134
- certificate_subject: x.subject,
135
- issuer: x.issuer,
136
- not_before: x.notBefore.toISOString(),
137
- not_after: x.notAfter.toISOString(),
138
- signed_at: now.toISOString(),
139
- serial_number: normalizeSerial(x.serialNumber),
140
- crl_urls: crlUrlsOf(x),
141
- issuer_certificate: issuerCert ? b64(issuerCert.rawData) : null
166
+ certificate_sha256: await sha256Hex(new Uint8Array(c.der)),
167
+ certificate_subject: c.x.subject,
168
+ issuer: c.x.issuer,
169
+ not_before: c.x.notBefore.toISOString(),
170
+ not_after: c.x.notAfter.toISOString(),
171
+ signed_at: c.now.toISOString(),
172
+ serial_number: normalizeSerial(c.x.serialNumber),
173
+ crl_urls: crlUrlsOf(c.x),
174
+ issuer_certificate: issuerCertificateOf(c)
175
+ };
176
+ }
177
+ /** Verifies a CMS over `challenge` by a natural person's e-CPF (D-29). Refuses an e-CNPJ. */
178
+ export async function verifyIcpPersonSignature(p) {
179
+ let cpf = "";
180
+ const c = await checkCms(p, (x) => {
181
+ if (cnpjFromCertificate(x))
182
+ throw new KyaError("CERTIFICATE_INVALID", "this is a company certificate (e-CNPJ); a person signs with their e-CPF");
183
+ const found = cpfFromCertificate(x);
184
+ if (!found)
185
+ throw new KyaError("CERTIFICATE_INVALID", "certificate has no ICP-Brasil CPF (not an e-CPF)");
186
+ cpf = found;
187
+ });
188
+ return {
189
+ cpf,
190
+ certificate_sha256: await sha256Hex(new Uint8Array(c.der)),
191
+ issuer: c.x.issuer,
192
+ not_before: c.x.notBefore.toISOString(),
193
+ not_after: c.x.notAfter.toISOString(),
194
+ signed_at: c.now.toISOString(),
195
+ serial_number: normalizeSerial(c.x.serialNumber),
196
+ crl_urls: crlUrlsOf(c.x),
197
+ issuer_certificate: issuerCertificateOf(c)
142
198
  };
143
199
  }
package/dist/index.d.ts CHANGED
@@ -8,3 +8,5 @@ export * from "./icp-brasil.js";
8
8
  export * from "./revocation.js";
9
9
  export * from "./erc8004.js";
10
10
  export * from "./ecnpj-sign.js";
11
+ export * from "./cloud-sign.js";
12
+ export * from "./person.js";
package/dist/index.js CHANGED
@@ -8,3 +8,5 @@ export * from "./icp-brasil.js";
8
8
  export * from "./revocation.js";
9
9
  export * from "./erc8004.js";
10
10
  export * from "./ecnpj-sign.js";
11
+ export * from "./cloud-sign.js";
12
+ export * from "./person.js";
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Natural persons as principals (D-29): "this agent key acts for a person who signed with an ICP-Brasil e-CPF".
3
+ * Privacy rules: the name is never stored or published; the CPF is never stored, published or revealed. The
4
+ * issuer keeps only HMAC(key, CPF) while a credential is alive, answers paying verifiers whether a CPF they
5
+ * already know matches, and gives each verifier its own pseudonymous subject reference for that person.
6
+ */
7
+ import { type AgentKey } from "./types.js";
8
+ import { type Challenge } from "./challenge.js";
9
+ /** 11 digits; throws INVALID_INPUT on anything that is not a valid CPF (check digits included). */
10
+ export declare function normalizeCpf(input: string): string;
11
+ export declare function isValidCpf(input: string): boolean;
12
+ /** Keyed hash of a CPF: what the issuer stores. Without the key, a CPF cannot be tested against it. */
13
+ export declare const personHash: (keyB64: string, cpf: string) => Promise<string>;
14
+ /**
15
+ * Pairwise subject reference: the same person gets a different, stable value for each verifier, so a verifier
16
+ * can track that person's agents on its side without two verifiers being able to link them.
17
+ */
18
+ export declare const subjectRef: (keyB64: string, hash: string, verifier: string) => Promise<string>;
19
+ /** Versioned keys from a JSON object {"1": "<base64 32 bytes>", "2": ...}; the highest version is current. */
20
+ export declare function personKeys(json: string | undefined): {
21
+ current: {
22
+ version: number;
23
+ key: string;
24
+ };
25
+ all: Map<number, string>;
26
+ } | null;
27
+ /** Consent the person signs with the e-CPF; part of the challenge text, so the signature records it. */
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). */
30
+ export declare function personChallengeText(p: {
31
+ issuer: string;
32
+ challenge_id: string;
33
+ agent: AgentKey;
34
+ expires_at: string;
35
+ }): string;
36
+ /** The text a person signs with the e-CPF to see their own agents (accepted for 10 minutes after `at`). */
37
+ export declare const personAgentsText: (at?: Date) => string;
38
+ export declare function newPersonChallenge(p: {
39
+ issuer: string;
40
+ agent: AgentKey;
41
+ now?: Date;
42
+ }): Challenge;
package/dist/person.js ADDED
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Natural persons as principals (D-29): "this agent key acts for a person who signed with an ICP-Brasil e-CPF".
3
+ * Privacy rules: the name is never stored or published; the CPF is never stored, published or revealed. The
4
+ * issuer keeps only HMAC(key, CPF) while a credential is alive, answers paying verifiers whether a CPF they
5
+ * already know matches, and gives each verifier its own pseudonymous subject reference for that person.
6
+ */
7
+ import { KyaError } from "./types.js";
8
+ import { CHALLENGE_TTL_SECONDS } from "./challenge.js";
9
+ /** 11 digits; throws INVALID_INPUT on anything that is not a valid CPF (check digits included). */
10
+ export function normalizeCpf(input) {
11
+ const d = String(input).replace(/\D/g, "");
12
+ if (!isValidCpf(d))
13
+ throw new KyaError("INVALID_INPUT", "cpf must be a valid Brazilian CPF");
14
+ return d;
15
+ }
16
+ export function isValidCpf(input) {
17
+ const d = String(input).replace(/\D/g, "");
18
+ if (d.length !== 11 || /^(\d)\1{10}$/.test(d))
19
+ return false;
20
+ const check = (len) => {
21
+ let sum = 0;
22
+ for (let i = 0; i < len; i++)
23
+ sum += Number(d[i]) * (len + 1 - i);
24
+ const r = (sum * 10) % 11;
25
+ return r === 10 ? 0 : r;
26
+ };
27
+ return check(9) === Number(d[9]) && check(10) === Number(d[10]);
28
+ }
29
+ const b64url = (u) => {
30
+ let s = "";
31
+ for (const b of u)
32
+ s += String.fromCharCode(b);
33
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
34
+ };
35
+ const unb64 = (s) => Uint8Array.from(atob(s.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0));
36
+ async function hmac(keyB64, message) {
37
+ const raw = unb64(keyB64);
38
+ if (raw.length < 32)
39
+ throw new KyaError("UPSTREAM_ERROR", "person key must be at least 32 bytes");
40
+ const key = await crypto.subtle.importKey("raw", raw.buffer, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
41
+ return b64url(new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(message))));
42
+ }
43
+ /** Keyed hash of a CPF: what the issuer stores. Without the key, a CPF cannot be tested against it. */
44
+ export const personHash = (keyB64, cpf) => hmac(keyB64, `kya:cpf:${normalizeCpf(cpf)}`);
45
+ /**
46
+ * Pairwise subject reference: the same person gets a different, stable value for each verifier, so a verifier
47
+ * can track that person's agents on its side without two verifiers being able to link them.
48
+ */
49
+ export const subjectRef = (keyB64, hash, verifier) => hmac(keyB64, `kya:subject:${hash}:${verifier.toLowerCase()}`);
50
+ /** Versioned keys from a JSON object {"1": "<base64 32 bytes>", "2": ...}; the highest version is current. */
51
+ export function personKeys(json) {
52
+ if (!json)
53
+ return null;
54
+ let parsed;
55
+ try {
56
+ parsed = JSON.parse(json);
57
+ }
58
+ catch {
59
+ throw new KyaError("UPSTREAM_ERROR", "PERSON_HMAC_KEYS is not valid JSON");
60
+ }
61
+ const all = new Map(Object.entries(parsed).map(([v, k]) => [Number(v), k]).filter(([v, k]) => Number.isInteger(v) && v > 0 && typeof k === "string"));
62
+ if (!all.size)
63
+ return null;
64
+ const version = Math.max(...all.keys());
65
+ return { current: { version, key: all.get(version) }, all };
66
+ }
67
+ /** Consent the person signs with the e-CPF; part of the challenge text, so the signature records it. */
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). */
70
+ export function personChallengeText(p) {
71
+ return [
72
+ "KYA Brasil: prove this agent key acts for me, a natural person",
73
+ `issuer: ${p.issuer}`,
74
+ `challenge: ${p.challenge_id}`,
75
+ `agent: ${p.agent.kind}:${p.agent.id}`,
76
+ "principal: person (e-CPF)",
77
+ `consent: ${PERSON_CONSENT}`,
78
+ `expires: ${p.expires_at}`
79
+ ].join("\n");
80
+ }
81
+ /** The text a person signs with the e-CPF to see their own agents (accepted for 10 minutes after `at`). */
82
+ export const personAgentsText = (at = new Date()) => `KYA Brasil: my agents\nat: ${at.toISOString()}`;
83
+ export function newPersonChallenge(p) {
84
+ const now = p.now ?? new Date();
85
+ const challenge_id = crypto.randomUUID();
86
+ 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 }) };
88
+ }
package/dist/types.d.ts CHANGED
@@ -67,22 +67,31 @@ export interface KybSnapshot {
67
67
  red_flags: string[];
68
68
  checked_at: string;
69
69
  }
70
+ export interface CompanyPrincipal {
71
+ kind?: "company";
72
+ cnpj: string;
73
+ cnpj_formatted: string;
74
+ legal_name: string;
75
+ registration_status: string;
76
+ }
77
+ /** D-29: a person who signed with an e-CPF. Never a name, a CPF or a hash of it; verifiers check a CPF they know through the issuer. */
78
+ export interface PersonPrincipal {
79
+ kind: "person";
80
+ verified_by: "icp-brasil-e-cpf";
81
+ }
82
+ export declare const isPersonPrincipal: (p: CompanyPrincipal | PersonPrincipal) => p is PersonPrincipal;
70
83
  /** The credential: "this agent key acts for this company, within this mandate, verified on this date". */
71
84
  export interface Credential {
72
85
  credential_id: string;
73
86
  version: 1;
74
87
  agent: AgentKey;
75
- principal: {
76
- cnpj: string;
77
- cnpj_formatted: string;
78
- legal_name: string;
79
- registration_status: string;
80
- };
81
- /** How the company authorized it: hash of the e-CNPJ certificate that signed the challenge (never the CPF of the signer). */
88
+ /** A company (e-CNPJ) or, since D-29, a natural person (e-CPF) with no personal data: tell them apart by `kind`. */
89
+ principal: CompanyPrincipal | PersonPrincipal;
90
+ /** How the principal authorized it: hash of the ICP-Brasil certificate that signed the challenge. No subject for persons. */
82
91
  authorized_by: {
83
- method: "icp-brasil-e-cnpj";
92
+ method: "icp-brasil-e-cnpj" | "icp-brasil-e-cpf";
84
93
  certificate_sha256: string;
85
- certificate_subject: string;
94
+ certificate_subject?: string;
86
95
  issuer: string;
87
96
  signed_at: string;
88
97
  };
package/dist/types.js CHANGED
@@ -1,4 +1,5 @@
1
1
  /** Shared types of KYA Brasil. Within /v1 fields are only added, never renamed or removed. */
2
+ export const isPersonPrincipal = (p) => p.kind === "person";
2
3
  export class KyaError extends Error {
3
4
  code;
4
5
  constructor(code, message) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kya-brasil/core",
3
- "version": "0.8.0",
3
+ "version": "0.10.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",