@kya-brasil/core 0.9.0 → 0.11.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 +15 -0
- package/dist/credential.d.ts +13 -0
- package/dist/credential.js +23 -0
- package/dist/delegation.d.ts +92 -0
- package/dist/delegation.js +268 -0
- package/dist/icp-brasil.d.ts +28 -2
- package/dist/icp-brasil.js +82 -26
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/mandate.d.ts +32 -1
- package/dist/mandate.js +48 -2
- package/dist/person.d.ts +42 -0
- package/dist/person.js +88 -0
- package/dist/types.d.ts +28 -9
- package/dist/types.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -43,6 +43,21 @@ const verdict = checkAction(credential.mandate, {
|
|
|
43
43
|
// verdict.allowed, verdict.checks (every rule evaluated, with its reason), verdict.not_evaluated (extensions)
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
+
## Delegation chains (sub-agents)
|
|
47
|
+
|
|
48
|
+
A mandate with `delegation: { max_depth, max_ttl_seconds }` lets the agent hand a narrower mandate to another key
|
|
49
|
+
(SPEC section 17). Verify a chain received in `KYA-Delegation`, offline, then evaluate the action on every level:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
import { checkDelegatedAction, decodeDelegationHeader, verifyDelegationChain } from "@kya-brasil/core";
|
|
53
|
+
const r = await verifyDelegationChain({ credential, chain: decodeDelegationHeader(header) });
|
|
54
|
+
if (!r.ok) reject(r.error); // r.delegate is the key that must sign the request
|
|
55
|
+
const verdict = checkDelegatedAction(r.mandates, { action: "quote", amount: 80, currency: "BRL" });
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
To delegate, `prepareDelegation({ credential, chain, delegate, mandate, ttlSeconds })` returns the delegation and
|
|
59
|
+
the text the delegator signs with its agent key; `mandateSubsetProblems(parent, child)` says why a mandate is broader.
|
|
60
|
+
|
|
46
61
|
## Payment instruments (card networks, acquirers, issuers)
|
|
47
62
|
|
|
48
63
|
A mandate can list the instruments the agent may pay with. Each one is a network and the SHA-256 of a reference
|
package/dist/credential.d.ts
CHANGED
|
@@ -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;
|
package/dist/credential.js
CHANGED
|
@@ -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);
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { type ActionQuery, type ActionVerdict } from "./mandate.js";
|
|
2
|
+
import { type AgentKey, type Mandate } from "./types.js";
|
|
3
|
+
/** A delegated mandate travels in a header on every request: keep it small. */
|
|
4
|
+
export declare const DELEGATED_MANDATE_MAX_BYTES = 2048;
|
|
5
|
+
export interface Delegation {
|
|
6
|
+
version: 1;
|
|
7
|
+
credential_id: string;
|
|
8
|
+
/** `signature_sha256` of the delegation this one extends; null at depth 1 (signed by the credential's agent). */
|
|
9
|
+
parent: string | null;
|
|
10
|
+
/** The key that receives the mandate (evm or web-bot-auth). */
|
|
11
|
+
delegate: AgentKey;
|
|
12
|
+
depth: number;
|
|
13
|
+
issued_at: string;
|
|
14
|
+
expires_at: string;
|
|
15
|
+
mandate: Mandate;
|
|
16
|
+
/** Signature by the delegator: the credential's agent at depth 1, the previous delegate after that. */
|
|
17
|
+
signature: string;
|
|
18
|
+
}
|
|
19
|
+
/** The credential fields a chain is checked against (a Credential, or the `credential` of a verification). */
|
|
20
|
+
export interface DelegationAnchor {
|
|
21
|
+
credential_id: string;
|
|
22
|
+
agent: AgentKey;
|
|
23
|
+
mandate: Mandate;
|
|
24
|
+
expires_at: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Why `child` is not contained in `parent` (empty array = contained). Every reserved field the parent restricts
|
|
28
|
+
* must be at least as restricted in the child; a child may restrict what the parent leaves open. Extensions
|
|
29
|
+
* (non-reserved fields) are not compared: they are never evaluated.
|
|
30
|
+
*/
|
|
31
|
+
export declare function mandateSubsetProblems(parent: Mandate, child: Mandate): string[];
|
|
32
|
+
export declare function isMandateSubset(parent: Mandate, child: Mandate): boolean;
|
|
33
|
+
/** Validates a mandate to delegate: the usual schema plus the size limit of delegated mandates. */
|
|
34
|
+
export declare function validateDelegatedMandate(input: unknown): Mandate;
|
|
35
|
+
/** The exact text the delegator signs. */
|
|
36
|
+
export declare function delegationText(d: Omit<Delegation, "signature" | "version">): Promise<string>;
|
|
37
|
+
/** Identifier of a signed delegation, used as `parent` by the next one. */
|
|
38
|
+
export declare function delegationRef(d: Pick<Delegation, "signature">): Promise<string>;
|
|
39
|
+
/**
|
|
40
|
+
* Builds the unsigned delegation that extends `chain` (empty = delegate from the credential itself) and the text
|
|
41
|
+
* to sign. The delegator signs `text` with its key and sets `signature`. Throws INVALID_INPUT when the policy
|
|
42
|
+
* does not allow it (depth, lifetime, mandate not contained in the delegator's).
|
|
43
|
+
*/
|
|
44
|
+
export declare function prepareDelegation(p: {
|
|
45
|
+
credential: DelegationAnchor;
|
|
46
|
+
chain?: Delegation[];
|
|
47
|
+
delegate: {
|
|
48
|
+
kind?: unknown;
|
|
49
|
+
id?: unknown;
|
|
50
|
+
};
|
|
51
|
+
mandate: unknown;
|
|
52
|
+
ttlSeconds: number;
|
|
53
|
+
now?: Date;
|
|
54
|
+
}): Promise<{
|
|
55
|
+
delegation: Omit<Delegation, "signature">;
|
|
56
|
+
text: string;
|
|
57
|
+
}>;
|
|
58
|
+
export type DelegationChainResult = {
|
|
59
|
+
ok: true;
|
|
60
|
+
delegate: AgentKey;
|
|
61
|
+
depth: number;
|
|
62
|
+
expires_at: string;
|
|
63
|
+
mandates: Mandate[];
|
|
64
|
+
ref: string;
|
|
65
|
+
} | {
|
|
66
|
+
ok: false;
|
|
67
|
+
error: string;
|
|
68
|
+
message: string;
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* Verifies a delegation chain against its credential, offline: every link signed by the key above it, depth and
|
|
72
|
+
* lifetime within each policy, each mandate contained in the one above, nothing expired. On success `delegate` is
|
|
73
|
+
* the key that must sign the request and `mandates` is every mandate an action must satisfy (credential first).
|
|
74
|
+
* The credential's own status (active, not revoked) is the caller's to check, as for any request.
|
|
75
|
+
*/
|
|
76
|
+
export declare function verifyDelegationChain(p: {
|
|
77
|
+
credential: DelegationAnchor;
|
|
78
|
+
chain: unknown;
|
|
79
|
+
now?: Date;
|
|
80
|
+
}): Promise<DelegationChainResult>;
|
|
81
|
+
/**
|
|
82
|
+
* Evaluates an action against every mandate of a verified chain (credential first). Allowed only when all allow
|
|
83
|
+
* it; `levels[0]` is the credential's mandate, `levels[n]` the mandate of delegation n.
|
|
84
|
+
*/
|
|
85
|
+
export declare function checkDelegatedAction(mandates: Mandate[], q: ActionQuery): {
|
|
86
|
+
allowed: boolean;
|
|
87
|
+
levels: ActionVerdict[];
|
|
88
|
+
};
|
|
89
|
+
/** Encodes a chain for the `KYA-Delegation` header (base64url of its JSON). */
|
|
90
|
+
export declare function encodeDelegationHeader(chain: Delegation[]): string;
|
|
91
|
+
/** Decodes a `KYA-Delegation` header; throws INVALID_INPUT when it is not base64url JSON. */
|
|
92
|
+
export declare function decodeDelegationHeader(value: string): unknown;
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delegation to sub-agents (D-30, docs/SPEC.md section 17).
|
|
3
|
+
*
|
|
4
|
+
* The principal allows it once, in the mandate it signs with its certificate (`delegation: { max_depth,
|
|
5
|
+
* max_ttl_seconds }`). The credential's agent then signs, with its own key, a short-lived delegation that hands a
|
|
6
|
+
* NARROWER mandate to another key; that key may delegate again while the depth allows. Nothing is stored by the
|
|
7
|
+
* issuer: the chain travels with the request (`KYA-Delegation`) and any verifier checks it offline against the
|
|
8
|
+
* credential. An action is allowed only when every mandate in the chain allows it.
|
|
9
|
+
*
|
|
10
|
+
* Delegation text (what the delegator signs; EIP-191 for evm/erc8004, raw Ed25519 for web-bot-auth):
|
|
11
|
+
* KYA Brasil: delegation
|
|
12
|
+
* credential: <credential_id>
|
|
13
|
+
* parent: <signature_sha256 of the previous delegation | ->
|
|
14
|
+
* delegate: <kind>:<id>
|
|
15
|
+
* depth: <1..3>
|
|
16
|
+
* issued: <ISO 8601>
|
|
17
|
+
* expires: <ISO 8601>
|
|
18
|
+
* mandate-sha256: <hex SHA-256 of the canonical JSON of the delegated mandate>
|
|
19
|
+
*/
|
|
20
|
+
import { canonicalize } from "./attestation.js";
|
|
21
|
+
import { normalizeAgentKey, verifyAgentSignature } from "./challenge.js";
|
|
22
|
+
import { DELEGATION_MAX_DEPTH, DELEGATION_MIN_TTL_SECONDS, checkAction, counterpartyMatches, normalizeCounterparty, validateMandate } from "./mandate.js";
|
|
23
|
+
import { KyaError } from "./types.js";
|
|
24
|
+
/** A delegated mandate travels in a header on every request: keep it small. */
|
|
25
|
+
export const DELEGATED_MANDATE_MAX_BYTES = 2048;
|
|
26
|
+
/** Clock difference tolerated on `issued_at`. */
|
|
27
|
+
const SKEW_MS = 300_000;
|
|
28
|
+
const hex = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
29
|
+
const sha256Hex = async (text) => hex(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)));
|
|
30
|
+
/**
|
|
31
|
+
* Why `child` is not contained in `parent` (empty array = contained). Every reserved field the parent restricts
|
|
32
|
+
* must be at least as restricted in the child; a child may restrict what the parent leaves open. Extensions
|
|
33
|
+
* (non-reserved fields) are not compared: they are never evaluated.
|
|
34
|
+
*/
|
|
35
|
+
export function mandateSubsetProblems(parent, child) {
|
|
36
|
+
const p = [];
|
|
37
|
+
if (parent.actions) {
|
|
38
|
+
if (!child.actions)
|
|
39
|
+
p.push("actions: parent restricts actions, delegated mandate must list them");
|
|
40
|
+
else
|
|
41
|
+
for (const a of child.actions)
|
|
42
|
+
if (!parent.actions.includes(a))
|
|
43
|
+
p.push(`actions: "${a}" is not in the parent mandate`);
|
|
44
|
+
}
|
|
45
|
+
for (const k of ["max_per_operation", "max_per_day"]) {
|
|
46
|
+
const pm = parent[k];
|
|
47
|
+
const cm = child[k];
|
|
48
|
+
if (!pm)
|
|
49
|
+
continue;
|
|
50
|
+
if (!cm)
|
|
51
|
+
p.push(`${k}: parent sets a ceiling, delegated mandate must too`);
|
|
52
|
+
else if (cm.currency !== pm.currency)
|
|
53
|
+
p.push(`${k}: currency ${cm.currency} differs from the parent's ${pm.currency}`);
|
|
54
|
+
else if (cm.amount > pm.amount)
|
|
55
|
+
p.push(`${k}: ${cm.amount} is above the parent's ${pm.amount}`);
|
|
56
|
+
}
|
|
57
|
+
if (parent.currencies) {
|
|
58
|
+
if (!child.currencies)
|
|
59
|
+
p.push("currencies: parent restricts currencies, delegated mandate must list them");
|
|
60
|
+
else
|
|
61
|
+
for (const c of child.currencies)
|
|
62
|
+
if (!parent.currencies.includes(c))
|
|
63
|
+
p.push(`currencies: ${c} is not in the parent mandate`);
|
|
64
|
+
}
|
|
65
|
+
if (parent.counterparties && parent.counterparties.length) {
|
|
66
|
+
if (!child.counterparties || !child.counterparties.length)
|
|
67
|
+
p.push("counterparties: parent restricts counterparties, delegated mandate must list them");
|
|
68
|
+
else
|
|
69
|
+
for (const c of child.counterparties)
|
|
70
|
+
if (!parent.counterparties.some((pc) => counterpartyMatches(pc, normalizeCounterparty(c))))
|
|
71
|
+
p.push(`counterparties: ${c} is not covered by the parent mandate`);
|
|
72
|
+
}
|
|
73
|
+
if (parent.payment_instruments && parent.payment_instruments.length) {
|
|
74
|
+
if (!child.payment_instruments || !child.payment_instruments.length)
|
|
75
|
+
p.push("payment_instruments: parent restricts instruments, delegated mandate must list them");
|
|
76
|
+
else
|
|
77
|
+
for (const i of child.payment_instruments)
|
|
78
|
+
if (!parent.payment_instruments.some((pi) => pi.network === i.network && pi.ref_sha256 === i.ref_sha256))
|
|
79
|
+
p.push(`payment_instruments: ${i.network} ${i.ref_sha256.slice(0, 12)}… is not in the parent mandate`);
|
|
80
|
+
}
|
|
81
|
+
if (parent.valid_from && (!child.valid_from || Date.parse(child.valid_from) < Date.parse(parent.valid_from)))
|
|
82
|
+
p.push("valid_from: delegated mandate starts before the parent's");
|
|
83
|
+
if (parent.valid_until && (!child.valid_until || Date.parse(child.valid_until) > Date.parse(parent.valid_until)))
|
|
84
|
+
p.push("valid_until: delegated mandate ends after the parent's");
|
|
85
|
+
if (child.delegation) {
|
|
86
|
+
if (!parent.delegation)
|
|
87
|
+
p.push("delegation: parent does not allow further delegation");
|
|
88
|
+
else {
|
|
89
|
+
if (child.delegation.max_depth > parent.delegation.max_depth - 1)
|
|
90
|
+
p.push(`delegation.max_depth: at most ${parent.delegation.max_depth - 1} below this level`);
|
|
91
|
+
if (child.delegation.max_ttl_seconds > parent.delegation.max_ttl_seconds)
|
|
92
|
+
p.push(`delegation.max_ttl_seconds: above the parent's ${parent.delegation.max_ttl_seconds}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return p;
|
|
96
|
+
}
|
|
97
|
+
export function isMandateSubset(parent, child) {
|
|
98
|
+
return mandateSubsetProblems(parent, child).length === 0;
|
|
99
|
+
}
|
|
100
|
+
/** Validates a mandate to delegate: the usual schema plus the size limit of delegated mandates. */
|
|
101
|
+
export function validateDelegatedMandate(input) {
|
|
102
|
+
const m = validateMandate(input);
|
|
103
|
+
if (JSON.stringify(m).length > DELEGATED_MANDATE_MAX_BYTES)
|
|
104
|
+
throw new KyaError("INVALID_INPUT", `delegated mandate larger than ${DELEGATED_MANDATE_MAX_BYTES} bytes`);
|
|
105
|
+
return m;
|
|
106
|
+
}
|
|
107
|
+
/** The exact text the delegator signs. */
|
|
108
|
+
export async function delegationText(d) {
|
|
109
|
+
return [
|
|
110
|
+
"KYA Brasil: delegation",
|
|
111
|
+
`credential: ${d.credential_id}`,
|
|
112
|
+
`parent: ${d.parent ?? "-"}`,
|
|
113
|
+
`delegate: ${d.delegate.kind}:${d.delegate.id}`,
|
|
114
|
+
`depth: ${d.depth}`,
|
|
115
|
+
`issued: ${d.issued_at}`,
|
|
116
|
+
`expires: ${d.expires_at}`,
|
|
117
|
+
`mandate-sha256: ${await sha256Hex(canonicalize(d.mandate))}`
|
|
118
|
+
].join("\n");
|
|
119
|
+
}
|
|
120
|
+
/** Identifier of a signed delegation, used as `parent` by the next one. */
|
|
121
|
+
export function delegationRef(d) {
|
|
122
|
+
return sha256Hex(d.signature);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Builds the unsigned delegation that extends `chain` (empty = delegate from the credential itself) and the text
|
|
126
|
+
* to sign. The delegator signs `text` with its key and sets `signature`. Throws INVALID_INPUT when the policy
|
|
127
|
+
* does not allow it (depth, lifetime, mandate not contained in the delegator's).
|
|
128
|
+
*/
|
|
129
|
+
export async function prepareDelegation(p) {
|
|
130
|
+
const chain = p.chain ?? [];
|
|
131
|
+
const now = p.now ?? new Date();
|
|
132
|
+
const delegate = normalizeAgentKey(p.delegate);
|
|
133
|
+
if (delegate.kind === "erc8004")
|
|
134
|
+
throw new KyaError("INVALID_INPUT", "delegate must be an evm or web-bot-auth key");
|
|
135
|
+
const mandate = validateDelegatedMandate(p.mandate);
|
|
136
|
+
const last = chain[chain.length - 1];
|
|
137
|
+
const parentMandate = last ? last.mandate : p.credential.mandate;
|
|
138
|
+
const policy = parentMandate.delegation;
|
|
139
|
+
const depth = chain.length + 1;
|
|
140
|
+
if (!policy)
|
|
141
|
+
throw new KyaError("INVALID_INPUT", depth === 1 ? "the credential's mandate does not allow delegation" : "the delegator's mandate does not allow further delegation");
|
|
142
|
+
if (depth > DELEGATION_MAX_DEPTH)
|
|
143
|
+
throw new KyaError("INVALID_INPUT", `delegation deeper than ${DELEGATION_MAX_DEPTH}`);
|
|
144
|
+
if (!Number.isInteger(p.ttlSeconds) || p.ttlSeconds < DELEGATION_MIN_TTL_SECONDS || p.ttlSeconds > policy.max_ttl_seconds)
|
|
145
|
+
throw new KyaError("INVALID_INPUT", `ttlSeconds must be from ${DELEGATION_MIN_TTL_SECONDS} to ${policy.max_ttl_seconds}`);
|
|
146
|
+
const problems = mandateSubsetProblems(parentMandate, mandate);
|
|
147
|
+
if (problems.length)
|
|
148
|
+
throw new KyaError("INVALID_INPUT", `delegated mandate is broader than the delegator's: ${problems.join("; ")}`);
|
|
149
|
+
let expires = now.getTime() + p.ttlSeconds * 1000;
|
|
150
|
+
for (const bound of [p.credential.expires_at, last?.expires_at])
|
|
151
|
+
if (bound)
|
|
152
|
+
expires = Math.min(expires, Date.parse(bound));
|
|
153
|
+
const delegation = {
|
|
154
|
+
version: 1,
|
|
155
|
+
credential_id: p.credential.credential_id,
|
|
156
|
+
parent: last ? await delegationRef(last) : null,
|
|
157
|
+
delegate,
|
|
158
|
+
depth,
|
|
159
|
+
issued_at: now.toISOString(),
|
|
160
|
+
expires_at: new Date(expires).toISOString(),
|
|
161
|
+
mandate
|
|
162
|
+
};
|
|
163
|
+
return { delegation, text: await delegationText(delegation) };
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Verifies a delegation chain against its credential, offline: every link signed by the key above it, depth and
|
|
167
|
+
* lifetime within each policy, each mandate contained in the one above, nothing expired. On success `delegate` is
|
|
168
|
+
* the key that must sign the request and `mandates` is every mandate an action must satisfy (credential first).
|
|
169
|
+
* The credential's own status (active, not revoked) is the caller's to check, as for any request.
|
|
170
|
+
*/
|
|
171
|
+
export async function verifyDelegationChain(p) {
|
|
172
|
+
const fail = (error, message) => ({ ok: false, error, message });
|
|
173
|
+
const now = (p.now ?? new Date()).getTime();
|
|
174
|
+
if (!Array.isArray(p.chain) || p.chain.length === 0)
|
|
175
|
+
return fail("KYA_DELEGATION_MALFORMED", "delegation chain must be a non-empty array");
|
|
176
|
+
if (p.chain.length > DELEGATION_MAX_DEPTH)
|
|
177
|
+
return fail("KYA_DELEGATION_TOO_DEEP", `delegation deeper than ${DELEGATION_MAX_DEPTH}`);
|
|
178
|
+
let signer = p.credential.agent;
|
|
179
|
+
let parentMandate = p.credential.mandate;
|
|
180
|
+
let parentRef = null;
|
|
181
|
+
let notAfter = Date.parse(p.credential.expires_at);
|
|
182
|
+
if (!Number.isFinite(notAfter))
|
|
183
|
+
return fail("KYA_DELEGATION_MALFORMED", "the credential has no valid expires_at");
|
|
184
|
+
const mandates = [p.credential.mandate];
|
|
185
|
+
for (let i = 0; i < p.chain.length; i++) {
|
|
186
|
+
const at = `delegation ${i + 1}`;
|
|
187
|
+
const raw = p.chain[i];
|
|
188
|
+
if (!raw || typeof raw !== "object" || raw.version !== 1 || typeof raw.signature !== "string" || typeof raw.issued_at !== "string" || typeof raw.expires_at !== "string")
|
|
189
|
+
return fail("KYA_DELEGATION_MALFORMED", `${at}: not a v1 delegation`);
|
|
190
|
+
if (raw.credential_id !== p.credential.credential_id)
|
|
191
|
+
return fail("KYA_DELEGATION_MISMATCH", `${at}: issued under another credential`);
|
|
192
|
+
if (raw.depth !== i + 1)
|
|
193
|
+
return fail("KYA_DELEGATION_MALFORMED", `${at}: depth must be ${i + 1}`);
|
|
194
|
+
if ((raw.parent ?? null) !== parentRef)
|
|
195
|
+
return fail("KYA_DELEGATION_MISMATCH", `${at}: does not extend the previous link`);
|
|
196
|
+
let delegate;
|
|
197
|
+
let mandate;
|
|
198
|
+
try {
|
|
199
|
+
delegate = normalizeAgentKey(raw.delegate ?? {});
|
|
200
|
+
if (delegate.kind === "erc8004")
|
|
201
|
+
throw new KyaError("INVALID_INPUT", "erc8004 delegate");
|
|
202
|
+
mandate = validateDelegatedMandate(raw.mandate);
|
|
203
|
+
}
|
|
204
|
+
catch (e) {
|
|
205
|
+
return fail("KYA_DELEGATION_MALFORMED", `${at}: ${e.message}`);
|
|
206
|
+
}
|
|
207
|
+
if (delegate.id !== raw.delegate?.id || canonicalize(mandate) !== canonicalize(raw.mandate))
|
|
208
|
+
return fail("KYA_DELEGATION_MALFORMED", `${at}: delegate or mandate not in normalized form`);
|
|
209
|
+
const policy = parentMandate.delegation;
|
|
210
|
+
if (!policy)
|
|
211
|
+
return fail("KYA_DELEGATION_NOT_ALLOWED", i === 0 ? "the credential's mandate does not allow delegation" : `${at}: the delegator's mandate does not allow further delegation`);
|
|
212
|
+
const issued = Date.parse(raw.issued_at);
|
|
213
|
+
const expires = Date.parse(raw.expires_at);
|
|
214
|
+
if (!Number.isFinite(issued) || !Number.isFinite(expires) || expires <= issued)
|
|
215
|
+
return fail("KYA_DELEGATION_MALFORMED", `${at}: bad issued_at / expires_at`);
|
|
216
|
+
if (expires - issued > policy.max_ttl_seconds * 1000)
|
|
217
|
+
return fail("KYA_DELEGATION_TTL", `${at}: lives longer than ${policy.max_ttl_seconds}s`);
|
|
218
|
+
if (expires > notAfter)
|
|
219
|
+
return fail("KYA_DELEGATION_TTL", `${at}: outlives the link above it`);
|
|
220
|
+
if (issued > now + SKEW_MS)
|
|
221
|
+
return fail("KYA_DELEGATION_NOT_YET_VALID", `${at}: issued in the future`);
|
|
222
|
+
if (expires < now)
|
|
223
|
+
return fail("KYA_DELEGATION_EXPIRED", `${at}: expired at ${raw.expires_at}`);
|
|
224
|
+
const problems = mandateSubsetProblems(parentMandate, mandate);
|
|
225
|
+
if (problems.length)
|
|
226
|
+
return fail("KYA_DELEGATION_BROADER", `${at}: ${problems.join("; ")}`);
|
|
227
|
+
const text = await delegationText({ credential_id: raw.credential_id, parent: parentRef, delegate, depth: i + 1, issued_at: raw.issued_at, expires_at: raw.expires_at, mandate });
|
|
228
|
+
if (!(await verifyAgentSignature(signer, text, raw.signature)))
|
|
229
|
+
return fail("KYA_DELEGATION_BAD_SIGNATURE", `${at}: not signed by the key above it`);
|
|
230
|
+
signer = delegate;
|
|
231
|
+
parentMandate = mandate;
|
|
232
|
+
parentRef = await delegationRef({ signature: raw.signature });
|
|
233
|
+
notAfter = expires;
|
|
234
|
+
mandates.push(mandate);
|
|
235
|
+
}
|
|
236
|
+
return { ok: true, delegate: signer, depth: p.chain.length, expires_at: new Date(notAfter).toISOString(), mandates, ref: parentRef };
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Evaluates an action against every mandate of a verified chain (credential first). Allowed only when all allow
|
|
240
|
+
* it; `levels[0]` is the credential's mandate, `levels[n]` the mandate of delegation n.
|
|
241
|
+
*/
|
|
242
|
+
export function checkDelegatedAction(mandates, q) {
|
|
243
|
+
const levels = mandates.map((m) => checkAction(m, q));
|
|
244
|
+
return { allowed: levels.length > 0 && levels.every((l) => l.allowed), levels };
|
|
245
|
+
}
|
|
246
|
+
/** Encodes a chain for the `KYA-Delegation` header (base64url of its JSON). */
|
|
247
|
+
export function encodeDelegationHeader(chain) {
|
|
248
|
+
const bytes = new TextEncoder().encode(JSON.stringify(chain));
|
|
249
|
+
let s = "";
|
|
250
|
+
for (const b of bytes)
|
|
251
|
+
s += String.fromCodePoint(b);
|
|
252
|
+
const b64 = btoa(s).replaceAll("+", "-").replaceAll("/", "_");
|
|
253
|
+
let end = b64.length;
|
|
254
|
+
while (end > 0 && b64[end - 1] === "=")
|
|
255
|
+
end--;
|
|
256
|
+
return b64.slice(0, end);
|
|
257
|
+
}
|
|
258
|
+
/** Decodes a `KYA-Delegation` header; throws INVALID_INPUT when it is not base64url JSON. */
|
|
259
|
+
export function decodeDelegationHeader(value) {
|
|
260
|
+
try {
|
|
261
|
+
const b64 = value.trim().replaceAll("-", "+").replaceAll("_", "/");
|
|
262
|
+
const bin = atob(b64.padEnd(Math.ceil(b64.length / 4) * 4, "="));
|
|
263
|
+
return JSON.parse(new TextDecoder().decode(Uint8Array.from(bin, (c) => c.codePointAt(0) ?? 0)));
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
throw new KyaError("INVALID_INPUT", "KYA-Delegation is not base64url JSON");
|
|
267
|
+
}
|
|
268
|
+
}
|
package/dist/icp-brasil.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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>;
|
package/dist/icp-brasil.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
*
|
|
68
|
-
*
|
|
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
|
-
|
|
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
|
|
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", "
|
|
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
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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:
|
|
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
package/dist/index.js
CHANGED
package/dist/mandate.d.ts
CHANGED
|
@@ -4,7 +4,11 @@
|
|
|
4
4
|
* `checkAction`; extensions are carried in the credential untouched and never evaluated.
|
|
5
5
|
* Validation is hand-written (no Ajv: `new Function` is blocked on Workers).
|
|
6
6
|
*/
|
|
7
|
-
import { type Mandate, type MandateCore } from "./types.js";
|
|
7
|
+
import { type DelegationPolicy, type Mandate, type MandateCore } from "./types.js";
|
|
8
|
+
/** Delegation bounds (D-30): how deep a chain may go and how long one link may live. */
|
|
9
|
+
export declare const DELEGATION_MAX_DEPTH = 3;
|
|
10
|
+
export declare const DELEGATION_MIN_TTL_SECONDS = 60;
|
|
11
|
+
export declare const DELEGATION_MAX_TTL_SECONDS = 86400;
|
|
8
12
|
export declare const MANDATE_SCHEMA: {
|
|
9
13
|
readonly $schema: "https://json-schema.org/draft/2020-12/schema";
|
|
10
14
|
readonly $id: "https://kya.example/.well-known/kya-mandate.schema.json";
|
|
@@ -72,6 +76,26 @@ export declare const MANDATE_SCHEMA: {
|
|
|
72
76
|
readonly additionalProperties: false;
|
|
73
77
|
};
|
|
74
78
|
};
|
|
79
|
+
readonly delegation: {
|
|
80
|
+
readonly type: "object";
|
|
81
|
+
readonly description: "Allows the agent to hand a narrower mandate to other keys (sub-agents), without a new certificate signature. Absent = no delegation.";
|
|
82
|
+
readonly properties: {
|
|
83
|
+
readonly max_depth: {
|
|
84
|
+
readonly type: "integer";
|
|
85
|
+
readonly minimum: 1;
|
|
86
|
+
readonly maximum: 3;
|
|
87
|
+
readonly description: "How many levels of sub-agents below this agent.";
|
|
88
|
+
};
|
|
89
|
+
readonly max_ttl_seconds: {
|
|
90
|
+
readonly type: "integer";
|
|
91
|
+
readonly minimum: 60;
|
|
92
|
+
readonly maximum: 86400;
|
|
93
|
+
readonly description: "Longest lifetime of one delegation.";
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
readonly required: readonly ["max_depth", "max_ttl_seconds"];
|
|
97
|
+
readonly additionalProperties: false;
|
|
98
|
+
};
|
|
75
99
|
readonly valid_from: {
|
|
76
100
|
readonly type: "string";
|
|
77
101
|
readonly format: "date-time";
|
|
@@ -102,12 +126,19 @@ export declare const MANDATE_SCHEMA: {
|
|
|
102
126
|
};
|
|
103
127
|
/** Throws KyaError INVALID_INPUT with the first problem found; returns the mandate with reserved fields normalized. */
|
|
104
128
|
export declare function validateMandate(input: unknown): Mandate;
|
|
129
|
+
/** Validates a `delegation` field of a mandate (used by validateMandate). */
|
|
130
|
+
export declare function validateDelegationPolicy(v: unknown): DelegationPolicy;
|
|
105
131
|
/**
|
|
106
132
|
* SHA-256 (lowercase hex) of a payment reference as a mandate stores it (D-25): UTF-8, whitespace removed,
|
|
107
133
|
* case kept. Use it on the network's token reference or PAR, never on a card number.
|
|
108
134
|
*/
|
|
109
135
|
export declare function hashPaymentRef(ref: string): Promise<string>;
|
|
110
136
|
export declare function normalizeCounterparty(c: string): string;
|
|
137
|
+
/**
|
|
138
|
+
* Whether a mandate entry matches a normalized counterparty: exactly, or for a `*.domain` entry the domain itself
|
|
139
|
+
* and any subdomain (SPEC section 5).
|
|
140
|
+
*/
|
|
141
|
+
export declare function counterpartyMatches(entry: string, counterparty: string): boolean;
|
|
111
142
|
export interface ActionQuery {
|
|
112
143
|
action?: string;
|
|
113
144
|
amount?: number;
|
package/dist/mandate.js
CHANGED
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
* Validation is hand-written (no Ajv: `new Function` is blocked on Workers).
|
|
6
6
|
*/
|
|
7
7
|
import { KyaError } from "./types.js";
|
|
8
|
+
/** Delegation bounds (D-30): how deep a chain may go and how long one link may live. */
|
|
9
|
+
export const DELEGATION_MAX_DEPTH = 3;
|
|
10
|
+
export const DELEGATION_MIN_TTL_SECONDS = 60;
|
|
11
|
+
export const DELEGATION_MAX_TTL_SECONDS = 86_400;
|
|
8
12
|
export const MANDATE_SCHEMA = {
|
|
9
13
|
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
10
14
|
$id: "https://kya.example/.well-known/kya-mandate.schema.json",
|
|
@@ -32,6 +36,16 @@ export const MANDATE_SCHEMA = {
|
|
|
32
36
|
additionalProperties: false
|
|
33
37
|
}
|
|
34
38
|
},
|
|
39
|
+
delegation: {
|
|
40
|
+
type: "object",
|
|
41
|
+
description: "Allows the agent to hand a narrower mandate to other keys (sub-agents), without a new certificate signature. Absent = no delegation.",
|
|
42
|
+
properties: {
|
|
43
|
+
max_depth: { type: "integer", minimum: 1, maximum: 3, description: "How many levels of sub-agents below this agent." },
|
|
44
|
+
max_ttl_seconds: { type: "integer", minimum: 60, maximum: 86400, description: "Longest lifetime of one delegation." }
|
|
45
|
+
},
|
|
46
|
+
required: ["max_depth", "max_ttl_seconds"],
|
|
47
|
+
additionalProperties: false
|
|
48
|
+
},
|
|
35
49
|
valid_from: { type: "string", format: "date-time" },
|
|
36
50
|
valid_until: { type: "string", format: "date-time" }
|
|
37
51
|
},
|
|
@@ -68,6 +82,8 @@ export function validateMandate(input) {
|
|
|
68
82
|
}
|
|
69
83
|
if (m.payment_instruments !== undefined)
|
|
70
84
|
m.payment_instruments = validatePaymentInstruments(m.payment_instruments, bad);
|
|
85
|
+
if (m.delegation !== undefined)
|
|
86
|
+
m.delegation = validateDelegationPolicy(m.delegation);
|
|
71
87
|
for (const k of ["valid_from", "valid_until"])
|
|
72
88
|
if (m[k] !== undefined && !isoDate(m[k]))
|
|
73
89
|
throw bad(k, "ISO 8601 date-time");
|
|
@@ -75,6 +91,20 @@ export function validateMandate(input) {
|
|
|
75
91
|
throw bad("valid_until", "must be after valid_from");
|
|
76
92
|
return m;
|
|
77
93
|
}
|
|
94
|
+
/** Validates a `delegation` field of a mandate (used by validateMandate). */
|
|
95
|
+
export function validateDelegationPolicy(v) {
|
|
96
|
+
const bad = (why) => new KyaError("INVALID_INPUT", `mandate.delegation: ${why}`);
|
|
97
|
+
if (!v || typeof v !== "object" || Array.isArray(v))
|
|
98
|
+
throw bad(`object { max_depth: 1..${DELEGATION_MAX_DEPTH}, max_ttl_seconds: ${DELEGATION_MIN_TTL_SECONDS}..${DELEGATION_MAX_TTL_SECONDS} }`);
|
|
99
|
+
const { max_depth, max_ttl_seconds, ...rest } = v;
|
|
100
|
+
if (Object.keys(rest).length)
|
|
101
|
+
throw bad(`unknown field(s): ${Object.keys(rest).join(", ")}`);
|
|
102
|
+
if (!Number.isInteger(max_depth) || max_depth < 1 || max_depth > DELEGATION_MAX_DEPTH)
|
|
103
|
+
throw bad(`max_depth must be an integer from 1 to ${DELEGATION_MAX_DEPTH}`);
|
|
104
|
+
if (!Number.isInteger(max_ttl_seconds) || max_ttl_seconds < DELEGATION_MIN_TTL_SECONDS || max_ttl_seconds > DELEGATION_MAX_TTL_SECONDS)
|
|
105
|
+
throw bad(`max_ttl_seconds must be an integer from ${DELEGATION_MIN_TTL_SECONDS} to ${DELEGATION_MAX_TTL_SECONDS}`);
|
|
106
|
+
return { max_depth: max_depth, max_ttl_seconds: max_ttl_seconds };
|
|
107
|
+
}
|
|
78
108
|
const NETWORK = /^[a-z][a-z0-9_-]{1,31}$/;
|
|
79
109
|
function validatePaymentInstruments(v, bad) {
|
|
80
110
|
if (!Array.isArray(v) || v.length > 20)
|
|
@@ -123,7 +153,22 @@ export function normalizeCounterparty(c) {
|
|
|
123
153
|
return digits.toUpperCase();
|
|
124
154
|
return s.toLowerCase();
|
|
125
155
|
}
|
|
126
|
-
|
|
156
|
+
/**
|
|
157
|
+
* Whether a mandate entry matches a normalized counterparty: exactly, or for a `*.domain` entry the domain itself
|
|
158
|
+
* and any subdomain (SPEC section 5).
|
|
159
|
+
*/
|
|
160
|
+
export function counterpartyMatches(entry, counterparty) {
|
|
161
|
+
const e = normalizeCounterparty(entry);
|
|
162
|
+
if (e === counterparty)
|
|
163
|
+
return true;
|
|
164
|
+
if (!e.startsWith("*."))
|
|
165
|
+
return false;
|
|
166
|
+
const base = e.slice(2);
|
|
167
|
+
return counterparty === base || counterparty.endsWith(`.${base}`);
|
|
168
|
+
}
|
|
169
|
+
// `delegation` is reserved (validated, never listed as not evaluated) but is not a rule on actions: it is
|
|
170
|
+
// enforced when a delegation chain is verified (delegation.ts).
|
|
171
|
+
const RESERVED = ["actions", "max_per_operation", "max_per_day", "currencies", "counterparties", "payment_instruments", "valid_from", "valid_until", "delegation"];
|
|
127
172
|
/** Deterministic evaluation of the reserved mandate fields against an intended action. */
|
|
128
173
|
export function checkAction(mandate, q) {
|
|
129
174
|
const now = q.now ?? new Date();
|
|
@@ -150,7 +195,8 @@ export function checkAction(mandate, q) {
|
|
|
150
195
|
}
|
|
151
196
|
if (mandate.counterparties && mandate.counterparties.length && q.counterparty) {
|
|
152
197
|
const c = normalizeCounterparty(q.counterparty);
|
|
153
|
-
|
|
198
|
+
const ok = mandate.counterparties.some((m) => counterpartyMatches(m, c));
|
|
199
|
+
push("counterparties", ok, `${c} ${ok ? "is" : "is not"} an allowed counterparty`);
|
|
154
200
|
}
|
|
155
201
|
if (mandate.payment_instruments && mandate.payment_instruments.length && q.payment_ref_sha256) {
|
|
156
202
|
const h = q.payment_ref_sha256.trim().toLowerCase();
|
package/dist/person.d.ts
ADDED
|
@@ -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
|
@@ -27,6 +27,11 @@ export interface PaymentInstrument {
|
|
|
27
27
|
/** Optional human label, e.g. "corporate card, purchasing". Never the card number. */
|
|
28
28
|
label?: string;
|
|
29
29
|
}
|
|
30
|
+
/** What a mandate allows its agent to delegate (D-30). */
|
|
31
|
+
export interface DelegationPolicy {
|
|
32
|
+
max_depth: number;
|
|
33
|
+
max_ttl_seconds: number;
|
|
34
|
+
}
|
|
30
35
|
export interface MandateCore {
|
|
31
36
|
/** Actions the agent may take on behalf of the company (free vocabulary, lowercase: buy, sell, quote, pay, sign, ...). */
|
|
32
37
|
actions?: string[];
|
|
@@ -50,6 +55,11 @@ export interface MandateCore {
|
|
|
50
55
|
* removed, case kept); empty or absent = any.
|
|
51
56
|
*/
|
|
52
57
|
payment_instruments?: PaymentInstrument[];
|
|
58
|
+
/**
|
|
59
|
+
* Delegation to sub-agents (D-30): the agent may hand a narrower mandate to other keys, up to `max_depth`
|
|
60
|
+
* levels below it, each delegation living at most `max_ttl_seconds`. Absent = the agent cannot delegate.
|
|
61
|
+
*/
|
|
62
|
+
delegation?: DelegationPolicy;
|
|
53
63
|
/** Mandate validity (ISO dates); the credential never outlives it. */
|
|
54
64
|
valid_from?: string;
|
|
55
65
|
valid_until?: string;
|
|
@@ -67,22 +77,31 @@ export interface KybSnapshot {
|
|
|
67
77
|
red_flags: string[];
|
|
68
78
|
checked_at: string;
|
|
69
79
|
}
|
|
80
|
+
export interface CompanyPrincipal {
|
|
81
|
+
kind?: "company";
|
|
82
|
+
cnpj: string;
|
|
83
|
+
cnpj_formatted: string;
|
|
84
|
+
legal_name: string;
|
|
85
|
+
registration_status: string;
|
|
86
|
+
}
|
|
87
|
+
/** 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. */
|
|
88
|
+
export interface PersonPrincipal {
|
|
89
|
+
kind: "person";
|
|
90
|
+
verified_by: "icp-brasil-e-cpf";
|
|
91
|
+
}
|
|
92
|
+
export declare const isPersonPrincipal: (p: CompanyPrincipal | PersonPrincipal) => p is PersonPrincipal;
|
|
70
93
|
/** The credential: "this agent key acts for this company, within this mandate, verified on this date". */
|
|
71
94
|
export interface Credential {
|
|
72
95
|
credential_id: string;
|
|
73
96
|
version: 1;
|
|
74
97
|
agent: AgentKey;
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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). */
|
|
98
|
+
/** A company (e-CNPJ) or, since D-29, a natural person (e-CPF) with no personal data: tell them apart by `kind`. */
|
|
99
|
+
principal: CompanyPrincipal | PersonPrincipal;
|
|
100
|
+
/** How the principal authorized it: hash of the ICP-Brasil certificate that signed the challenge. No subject for persons. */
|
|
82
101
|
authorized_by: {
|
|
83
|
-
method: "icp-brasil-e-cnpj";
|
|
102
|
+
method: "icp-brasil-e-cnpj" | "icp-brasil-e-cpf";
|
|
84
103
|
certificate_sha256: string;
|
|
85
|
-
certificate_subject
|
|
104
|
+
certificate_subject?: string;
|
|
86
105
|
issuer: string;
|
|
87
106
|
signed_at: string;
|
|
88
107
|
};
|
package/dist/types.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kya-brasil/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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",
|