@kya-brasil/core 0.10.0 → 0.12.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
@@ -43,6 +43,51 @@ 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
+
61
+ ## Status freshness proportional to the amount (offline)
62
+
63
+ Offline, a verifier only has status evidence of some age: an answer the issuer signed (the credential's status) or a
64
+ CRL (the certificate's). How old is acceptable is the verifier's call, because the verifier runs the risk (SPEC
65
+ section 18). Declare the expected loss you accept per action (`L`) and the revocation rate you assume (`λ`, per hour);
66
+ the maximum age is `L / (amount × λ)`, capped (one day by default):
67
+
68
+ ```ts
69
+ import { freshnessPolicy, checkFreshAction, evidenceFromSignedStatus, evidenceFromRevocation } from "@kya-brasil/core";
70
+
71
+ const policy = freshnessPolicy({
72
+ max_expected_loss: { amount: 1, currency: "BRL" }, // L: your tolerance, per action
73
+ revocation_rate_per_hour: { credential: lambda, certificate: mu }, // λ: what you assume; no default
74
+ require: ["credential"], // add "certificate" to also require a CRL
75
+ // max_age_seconds: 86_400, max_clock_skew_seconds: 300, or max_age: ({ amount, kind }) => seconds
76
+ });
77
+
78
+ // The agent may staple a recent signed verdict to its request: then no network is needed.
79
+ const evidence = [await evidenceFromSignedStatus(stapledVerdict, issuerPublicJwk, credential.credential_id)];
80
+ // evidence.push(evidenceFromRevocation(await checkRevocation({ crlDer, serial, issuerCertificate })));
81
+
82
+ const r = checkFreshAction(credential.mandate, { action: "buy", amount: 4200, currency: "BRL" }, { policy, evidence });
83
+ // r.allowed; r.freshness.refresh = kinds to fetch again (or ask the agent for); r.freshness.applied = policy and
84
+ // clock used, keep it with the verdict as the risk you accepted
85
+ ```
86
+
87
+ It fails closed: no evidence, another currency, an invalid amount or clock, evidence dated in the future beyond the
88
+ skew, or a CRL past its `nextUpdate`. The freshest statement wins, so a later revocation beats an earlier "active".
89
+ `checkFreshness` and `maxStatusAgeSeconds` are available on their own.
90
+
46
91
  ## Payment instruments (card networks, acquirers, issuers)
47
92
 
48
93
  A mandate can list the instruments the agent may pay with. Each one is a network and the SHA-256 of a reference
@@ -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
+ }
@@ -0,0 +1,119 @@
1
+ import { type ActionQuery, type ActionVerdict } from "./mandate.js";
2
+ import { type Mandate } from "./types.js";
3
+ import type { RevocationStatus } from "./revocation.js";
4
+ /** What a piece of evidence is about: the KYA credential (issuer status) or the ICP-Brasil certificate (CRL). */
5
+ export type EvidenceKind = "credential" | "certificate";
6
+ export interface StatusEvidence {
7
+ kind: EvidenceKind;
8
+ /** True when the evidence says the subject is in force: credential `active`, certificate not on the CRL. */
9
+ in_force: boolean;
10
+ /** Instant at which the status was true: the issuer's `attestation.signed_at`, or the CRL's thisUpdate. */
11
+ as_of: string;
12
+ /** Instant after which the evidence must not be used at all (a CRL's nextUpdate). */
13
+ valid_until?: string | null;
14
+ /** Free text for the verdict, e.g. "issuer verdict" or "CRL of AC X". */
15
+ source?: string;
16
+ /** Status as stated by the evidence, for the verdict ("active", "revoked", "certificateHold", ...). */
17
+ status?: string;
18
+ }
19
+ export declare const FRESHNESS_DEFAULT_MAX_AGE_SECONDS = 86400;
20
+ export declare const FRESHNESS_LIMIT_MAX_AGE_SECONDS: number;
21
+ export declare const FRESHNESS_DEFAULT_CLOCK_SKEW_SECONDS = 300;
22
+ export interface FreshnessPolicyInput {
23
+ /** L: the expected loss the verifier accepts per action, in the currency of the amounts it checks. */
24
+ max_expected_loss: {
25
+ amount: number;
26
+ currency: string;
27
+ };
28
+ /** λ: probability per hour that a subject in force stops being so, per kind of evidence. */
29
+ revocation_rate_per_hour: Partial<Record<EvidenceKind, number>>;
30
+ /** Kinds of evidence an action needs. Default ["credential"]. */
31
+ require?: EvidenceKind[];
32
+ /** Ceiling on the evidence age, whatever the amount. Default 86400 (one day), at most 90 days. */
33
+ max_age_seconds?: number;
34
+ /** Tolerance for evidence dated after the verifier's clock. Default 300, at most 3600. */
35
+ max_clock_skew_seconds?: number;
36
+ /**
37
+ * Replaces the default formula. Receives the amount (undefined when the action has none) and the kind;
38
+ * returns seconds. The result is clamped to [0, max_age_seconds]; anything not finite counts as 0.
39
+ */
40
+ max_age?: (p: {
41
+ amount: number | undefined;
42
+ currency: string;
43
+ kind: EvidenceKind;
44
+ }) => number;
45
+ }
46
+ export interface FreshnessPolicy {
47
+ max_expected_loss: {
48
+ amount: number;
49
+ currency: string;
50
+ };
51
+ revocation_rate_per_hour: Partial<Record<EvidenceKind, number>>;
52
+ require: EvidenceKind[];
53
+ max_age_seconds: number;
54
+ max_clock_skew_seconds: number;
55
+ max_age?: FreshnessPolicyInput["max_age"];
56
+ }
57
+ /** Validates and normalizes a policy. Throws INVALID_INPUT on anything that would make it fail open. */
58
+ export declare function freshnessPolicy(input: FreshnessPolicyInput): FreshnessPolicy;
59
+ /** Maximum evidence age, in whole seconds, for an action of `amount` (in the policy's currency). */
60
+ export declare function maxStatusAgeSeconds(policy: FreshnessPolicy, kind: EvidenceKind, amount?: number): number;
61
+ export interface FreshnessCheck {
62
+ rule: "freshness";
63
+ kind: EvidenceKind;
64
+ ok: boolean;
65
+ detail: string;
66
+ age_seconds: number | null;
67
+ max_age_seconds: number;
68
+ }
69
+ export interface FreshnessVerdict {
70
+ ok: boolean;
71
+ checks: FreshnessCheck[];
72
+ /** Kinds for which fresher (or any) evidence would change the answer: fetch it, or ask the agent for it. */
73
+ refresh: EvidenceKind[];
74
+ /** The policy and clock actually applied, to keep with the verdict as proof of the risk accepted. */
75
+ applied: {
76
+ now: string;
77
+ amount: number | null;
78
+ currency: string;
79
+ max_expected_loss: {
80
+ amount: number;
81
+ currency: string;
82
+ };
83
+ revocation_rate_per_hour: Partial<Record<EvidenceKind, number>>;
84
+ max_age_seconds: number;
85
+ max_clock_skew_seconds: number;
86
+ custom_max_age: boolean;
87
+ };
88
+ }
89
+ /**
90
+ * Checks that, for every kind the policy requires, the freshest evidence given says "in force", is not
91
+ * past its own validity, is not dated in the future beyond the clock skew, and is younger than the maximum
92
+ * age for this amount. Fails closed: missing, malformed or foreign-currency input never passes.
93
+ */
94
+ export declare function checkFreshness(policy: FreshnessPolicy, evidence: StatusEvidence[], q?: {
95
+ amount?: number;
96
+ currency?: string;
97
+ now?: Date;
98
+ }): FreshnessVerdict;
99
+ /**
100
+ * The mandate (checkAction) and the freshness of the status, in one answer: allowed only when both pass.
101
+ * The freshness check uses the action's amount and currency and the same clock.
102
+ */
103
+ export declare function checkFreshAction(mandate: Mandate, q: ActionQuery, p: {
104
+ policy: FreshnessPolicy;
105
+ evidence: StatusEvidence[];
106
+ }): {
107
+ allowed: boolean;
108
+ mandate: ActionVerdict;
109
+ freshness: FreshnessVerdict;
110
+ };
111
+ /** Evidence about the certificate from a CRL lookup (checkRevocation / verifyCrl().lookup). */
112
+ export declare function evidenceFromRevocation(r: RevocationStatus, source?: string): StatusEvidence;
113
+ /**
114
+ * Evidence about the credential from an answer the issuer signed (a verdict from /allowed, a paid
115
+ * verification receipt): any answer carrying `credential_id`, `status` and an Ed25519 `attestation`. The
116
+ * agent can staple such an answer to its request so the verifier needs no network. The answer must be
117
+ * about `credentialId` and verify with the issuer's key; the status instant is the signature's.
118
+ */
119
+ export declare function evidenceFromSignedStatus(answer: Record<string, unknown>, issuerPublicJwk: JsonWebKey, credentialId: string): Promise<StatusEvidence>;
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Status freshness proportional to what is at stake (D-32, SPEC section 18).
3
+ *
4
+ * A verifier working offline holds evidence of status that is some time old: an issuer answer signed at a
5
+ * given instant (the credential's status), or a CRL issued at a given instant (the e-CNPJ / e-CPF
6
+ * certificate's status). Following Rivest (1998), the recency requirement is set by whoever accepts the
7
+ * action and runs the risk, not by the issuer: this module turns the verifier's loss tolerance into a
8
+ * maximum evidence age that shrinks as the amount grows, and refuses older evidence.
9
+ *
10
+ * Expected loss of accepting an action of value v on evidence of age t, when status changes at a rate of
11
+ * λ per hour: v × λ × t. Keeping it under the tolerance L gives the default maximum age
12
+ *
13
+ * max_age(v) = L / (v × λ) hours, capped by max_age_seconds.
14
+ *
15
+ * Pure functions, no network: the caller obtains the evidence (cache, CRL download, an issuer answer the
16
+ * agent staples to its request) and passes the clock it trusts.
17
+ */
18
+ import { verifyAttestation } from "./attestation.js";
19
+ import { checkAction } from "./mandate.js";
20
+ import { KyaError } from "./types.js";
21
+ export const FRESHNESS_DEFAULT_MAX_AGE_SECONDS = 86_400;
22
+ export const FRESHNESS_LIMIT_MAX_AGE_SECONDS = 90 * 86_400;
23
+ export const FRESHNESS_DEFAULT_CLOCK_SKEW_SECONDS = 300;
24
+ const KINDS = ["credential", "certificate"];
25
+ const CURRENCY = /^[A-Z0-9]{2,10}$/;
26
+ function bad(message) {
27
+ throw new KyaError("INVALID_INPUT", `freshness policy: ${message}`);
28
+ }
29
+ /** Validates and normalizes a policy. Throws INVALID_INPUT on anything that would make it fail open. */
30
+ export function freshnessPolicy(input) {
31
+ if (!input || typeof input !== "object")
32
+ bad("expected an object");
33
+ const loss = input.max_expected_loss;
34
+ if (!loss || typeof loss.amount !== "number" || !Number.isFinite(loss.amount) || loss.amount < 0)
35
+ bad("max_expected_loss.amount must be a finite number >= 0");
36
+ const currency = typeof loss.currency === "string" ? loss.currency.trim().toUpperCase() : "";
37
+ if (!CURRENCY.test(currency))
38
+ bad("max_expected_loss.currency must match ^[A-Z0-9]{2,10}$");
39
+ const require = input.require ?? ["credential"];
40
+ if (!Array.isArray(require) || !require.length || require.some((k) => !KINDS.includes(k)))
41
+ bad(`require must list one or more of ${KINDS.join(", ")}`);
42
+ const rates = {};
43
+ for (const [k, v] of Object.entries(input.revocation_rate_per_hour ?? {})) {
44
+ if (!KINDS.includes(k))
45
+ bad(`unknown evidence kind "${k}" in revocation_rate_per_hour`);
46
+ if (typeof v !== "number" || !Number.isFinite(v) || v < 0)
47
+ bad(`revocation_rate_per_hour.${k} must be a finite number >= 0`);
48
+ rates[k] = v;
49
+ }
50
+ if (!input.max_age)
51
+ for (const k of require)
52
+ if (rates[k] === undefined)
53
+ bad(`revocation_rate_per_hour.${k} is required (or give max_age)`);
54
+ const ceiling = input.max_age_seconds ?? FRESHNESS_DEFAULT_MAX_AGE_SECONDS;
55
+ if (!Number.isInteger(ceiling) || ceiling < 0 || ceiling > FRESHNESS_LIMIT_MAX_AGE_SECONDS)
56
+ bad(`max_age_seconds must be an integer in 0..${FRESHNESS_LIMIT_MAX_AGE_SECONDS}`);
57
+ const skew = input.max_clock_skew_seconds ?? FRESHNESS_DEFAULT_CLOCK_SKEW_SECONDS;
58
+ if (!Number.isInteger(skew) || skew < 0 || skew > 3600)
59
+ bad("max_clock_skew_seconds must be an integer in 0..3600");
60
+ if (input.max_age !== undefined && typeof input.max_age !== "function")
61
+ bad("max_age must be a function");
62
+ return { max_expected_loss: { amount: loss.amount, currency }, revocation_rate_per_hour: rates, require: [...new Set(require)], max_age_seconds: ceiling, max_clock_skew_seconds: skew, max_age: input.max_age };
63
+ }
64
+ /** Maximum evidence age, in whole seconds, for an action of `amount` (in the policy's currency). */
65
+ export function maxStatusAgeSeconds(policy, kind, amount) {
66
+ const ceiling = policy.max_age_seconds;
67
+ let seconds;
68
+ if (policy.max_age) {
69
+ seconds = policy.max_age({ amount, currency: policy.max_expected_loss.currency, kind });
70
+ }
71
+ else {
72
+ const rate = policy.revocation_rate_per_hour[kind] ?? 0;
73
+ if (amount === undefined || amount <= 0 || rate === 0)
74
+ return ceiling;
75
+ seconds = (policy.max_expected_loss.amount / (amount * rate)) * 3600;
76
+ }
77
+ if (!Number.isFinite(seconds) || seconds < 0)
78
+ return seconds === Infinity ? ceiling : 0;
79
+ // The epsilon keeps binary rounding (1 / (10000 × 1e-4) × 3600 = 3599.999…) from costing a whole second.
80
+ return Math.min(ceiling, Math.floor(seconds + 1e-6));
81
+ }
82
+ function human(seconds) {
83
+ if (seconds < 120)
84
+ return `${seconds} s`;
85
+ if (seconds < 7200)
86
+ return `${Math.floor(seconds / 60)} min`;
87
+ if (seconds < 172_800)
88
+ return `${Math.floor(seconds / 3600)} h`;
89
+ return `${Math.floor(seconds / 86_400)} d`;
90
+ }
91
+ /**
92
+ * Checks that, for every kind the policy requires, the freshest evidence given says "in force", is not
93
+ * past its own validity, is not dated in the future beyond the clock skew, and is younger than the maximum
94
+ * age for this amount. Fails closed: missing, malformed or foreign-currency input never passes.
95
+ */
96
+ export function checkFreshness(policy, evidence, q = {}) {
97
+ const now = q.now ?? new Date();
98
+ const nowMs = now.getTime();
99
+ const currency = policy.max_expected_loss.currency;
100
+ const qCurrency = q.currency?.trim().toUpperCase();
101
+ const amount = q.amount;
102
+ const applied = {
103
+ now: Number.isNaN(nowMs) ? "invalid" : now.toISOString(),
104
+ amount: amount ?? null,
105
+ currency,
106
+ max_expected_loss: policy.max_expected_loss,
107
+ revocation_rate_per_hour: policy.revocation_rate_per_hour,
108
+ max_age_seconds: policy.max_age_seconds,
109
+ max_clock_skew_seconds: policy.max_clock_skew_seconds,
110
+ custom_max_age: !!policy.max_age
111
+ };
112
+ const checks = [];
113
+ const refresh = [];
114
+ if (Number.isNaN(nowMs)) {
115
+ for (const kind of policy.require)
116
+ checks.push({ rule: "freshness", kind, ok: false, detail: "the verifier's clock is not a valid date", age_seconds: null, max_age_seconds: 0 });
117
+ return { ok: false, checks, refresh, applied };
118
+ }
119
+ const amountOk = amount === undefined || (typeof amount === "number" && Number.isFinite(amount) && amount >= 0);
120
+ if (!amountOk || (qCurrency && qCurrency !== currency)) {
121
+ const detail = !amountOk ? "amount is not a finite number >= 0" : `loss tolerance is in ${currency}, operation in ${qCurrency}`;
122
+ for (const kind of policy.require)
123
+ checks.push({ rule: "freshness", kind, ok: false, detail, age_seconds: null, max_age_seconds: 0 });
124
+ return { ok: false, checks, refresh, applied };
125
+ }
126
+ for (const kind of policy.require) {
127
+ const max = maxStatusAgeSeconds(policy, kind, amount);
128
+ const limit = `${amount === undefined ? "no amount given, ceiling applies" : `for ${amount} ${currency}`}: at most ${human(max)}`;
129
+ const usable = evidence.filter((e) => e && e.kind === kind && !Number.isNaN(Date.parse(e.as_of)));
130
+ if (!usable.length) {
131
+ checks.push({ rule: "freshness", kind, ok: false, detail: `no ${kind} status evidence; ${limit}`, age_seconds: null, max_age_seconds: max });
132
+ refresh.push(kind);
133
+ continue;
134
+ }
135
+ // Freshest first: a newer statement supersedes an older one, whatever it says.
136
+ let e = usable[0];
137
+ let asOf = Date.parse(e.as_of);
138
+ for (const candidate of usable) {
139
+ const t = Date.parse(candidate.as_of);
140
+ if (t > asOf) {
141
+ e = candidate;
142
+ asOf = t;
143
+ }
144
+ }
145
+ const age = Math.floor((nowMs - asOf) / 1000);
146
+ const from = e.source ? ` (${e.source})` : "";
147
+ if (asOf - nowMs > policy.max_clock_skew_seconds * 1000) {
148
+ checks.push({ rule: "freshness", kind, ok: false, detail: `${kind} evidence${from} is dated ${e.as_of}, after the verifier's clock ${applied.now} beyond the ${policy.max_clock_skew_seconds} s tolerance`, age_seconds: age, max_age_seconds: max });
149
+ continue;
150
+ }
151
+ if (e.valid_until && !Number.isNaN(Date.parse(e.valid_until)) && Date.parse(e.valid_until) < nowMs) {
152
+ checks.push({ rule: "freshness", kind, ok: false, detail: `${kind} evidence${from} expired at ${e.valid_until}`, age_seconds: Math.max(0, age), max_age_seconds: max });
153
+ refresh.push(kind);
154
+ continue;
155
+ }
156
+ if (!e.in_force) {
157
+ checks.push({ rule: "freshness", kind, ok: false, detail: `${kind} is not in force${e.status ? ` (${e.status})` : ""} as of ${e.as_of}${from}`, age_seconds: Math.max(0, age), max_age_seconds: max });
158
+ continue;
159
+ }
160
+ const ageShown = Math.max(0, age);
161
+ const fresh = ageShown <= max;
162
+ checks.push({ rule: "freshness", kind, ok: fresh, detail: `${kind} status${from} is ${human(ageShown)} old; ${limit}`, age_seconds: ageShown, max_age_seconds: max });
163
+ if (!fresh)
164
+ refresh.push(kind);
165
+ }
166
+ return { ok: checks.length > 0 && checks.every((c) => c.ok), checks, refresh, applied };
167
+ }
168
+ /**
169
+ * The mandate (checkAction) and the freshness of the status, in one answer: allowed only when both pass.
170
+ * The freshness check uses the action's amount and currency and the same clock.
171
+ */
172
+ export function checkFreshAction(mandate, q, p) {
173
+ const now = q.now ?? new Date();
174
+ const m = checkAction(mandate, { ...q, now });
175
+ const f = checkFreshness(p.policy, p.evidence, { amount: q.amount, currency: q.currency, now });
176
+ return { allowed: m.allowed && f.ok, mandate: m, freshness: f };
177
+ }
178
+ /** Evidence about the certificate from a CRL lookup (checkRevocation / verifyCrl().lookup). */
179
+ export function evidenceFromRevocation(r, source) {
180
+ return {
181
+ kind: "certificate",
182
+ in_force: !r.revoked,
183
+ as_of: r.crl_this_update,
184
+ valid_until: r.crl_next_update,
185
+ source: source ?? "CRL",
186
+ status: r.revoked ? (r.reason ?? "revoked") : "not revoked"
187
+ };
188
+ }
189
+ /**
190
+ * Evidence about the credential from an answer the issuer signed (a verdict from /allowed, a paid
191
+ * verification receipt): any answer carrying `credential_id`, `status` and an Ed25519 `attestation`. The
192
+ * agent can staple such an answer to its request so the verifier needs no network. The answer must be
193
+ * about `credentialId` and verify with the issuer's key; the status instant is the signature's.
194
+ */
195
+ export async function evidenceFromSignedStatus(answer, issuerPublicJwk, credentialId) {
196
+ if (!answer || typeof answer !== "object")
197
+ throw new KyaError("INVALID_INPUT", "signed status: expected an object");
198
+ if (answer.credential_id !== credentialId)
199
+ throw new KyaError("INVALID_INPUT", `signed status is about ${String(answer.credential_id)}, not ${credentialId}`);
200
+ if (typeof answer.status !== "string")
201
+ throw new KyaError("INVALID_INPUT", "signed status has no status");
202
+ const sig = await verifyAttestation(answer, issuerPublicJwk);
203
+ if (!sig.valid || !sig.signed_at)
204
+ throw new KyaError("INVALID_INPUT", `signed status does not verify: ${sig.reason ?? "no signature time"}`);
205
+ return { kind: "credential", in_force: answer.status === "active", as_of: sig.signed_at, source: "issuer signed status", status: answer.status };
206
+ }
package/dist/index.d.ts CHANGED
@@ -10,3 +10,5 @@ export * from "./erc8004.js";
10
10
  export * from "./ecnpj-sign.js";
11
11
  export * from "./cloud-sign.js";
12
12
  export * from "./person.js";
13
+ export * from "./delegation.js";
14
+ export * from "./freshness.js";
package/dist/index.js CHANGED
@@ -10,3 +10,5 @@ export * from "./erc8004.js";
10
10
  export * from "./ecnpj-sign.js";
11
11
  export * from "./cloud-sign.js";
12
12
  export * from "./person.js";
13
+ export * from "./delegation.js";
14
+ export * from "./freshness.js";
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
- const RESERVED = ["actions", "max_per_operation", "max_per_day", "currencies", "counterparties", "payment_instruments", "valid_from", "valid_until"];
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
- push("counterparties", mandate.counterparties.includes(c), `${c} ${mandate.counterparties.includes(c) ? "is" : "is not"} an allowed counterparty`);
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/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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kya-brasil/core",
3
- "version": "0.10.0",
3
+ "version": "0.12.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",