@kya-brasil/agent-tools 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
@@ -100,6 +100,64 @@ const { response, callee } = await me.fetch("https://beta.com.br/orders", { meth
100
100
  if (!callee?.ok) throw new Error("unsigned or invalid callee"); // callee.kya: principal, mandate, status
101
101
  ```
102
102
 
103
+ ### Sub-agents (delegation, D-30): one certificate signature for the whole tree
104
+
105
+ When the mandate carries `delegation: { max_depth, max_ttl_seconds }`, the agent hands a narrower mandate to
106
+ another key with its own signature, no new e-CNPJ or e-CPF signature. The sub-agent signs its requests with its own
107
+ key and sends the chain; your middleware verifies it offline and `check()` / `allowed()` evaluate every mandate in it.
108
+
109
+ ```ts
110
+ import { signDelegation, signRequest } from "@kya-brasil/agent-tools";
111
+ const chain = await signDelegation({ credential, key: agentKey, delegate: { kind: "web-bot-auth", id: subPublicKey },
112
+ mandate: { actions: ["quote"], max_per_operation: { amount: 500, currency: "BRL" } }, ttlSeconds: 600 });
113
+ const headers = await signRequest({ credentialId, key: subKey, delegation: chain, method: "GET", url });
114
+ // server side: c.get("kya").delegation → { depth, delegate, mandates, expires_at, chain }
115
+ ```
116
+
117
+ Short-lived (at most `max_ttl_seconds`, never past the credential), contained in the parent mandate, at most 3
118
+ levels; revoking the credential ends the whole tree. The issuer stores nothing. [SPEC](https://github.com/celfons/kya-brasil/blob/main/docs/SPEC.md) section 17.
119
+
120
+ ### Verify without the issuer (offline)
121
+
122
+ Opt-in. The agent sends its credential and the principal's proof with the request; the middleware checks them
123
+ against ICP-Brasil alone and never calls the issuer (or any network: the certificate's status is your callback).
124
+ Works with self-issued credentials (`issuer: "urn:kya-brasil:self"`, issued with `@kya-brasil/core`'s
125
+ `assembleCredential`) and with credentials from an issuer whose public key you list.
126
+
127
+ ```ts
128
+ import { createKyaAuth, signRequest } from "@kya-brasil/agent-tools";
129
+ import { checkRevocation, evidenceFromRevocation } from "@kya-brasil/core";
130
+
131
+ // agent: credential and proof as assembleCredential returned them (or the issuer's credential + your .p7s proof)
132
+ const headers = await signRequest({ credentialId, key, method: "POST", url, body, present: { credential, proof } });
133
+
134
+ // server: nothing has a default; construction throws without anchors, now or certificateStatus
135
+ const auth = createKyaAuth({
136
+ offline: {
137
+ anchors: icpBrasilRoots, // base64 DER / PEM
138
+ now: () => new Date(), // your clock
139
+ certificateStatus: async ({ revocation }) => // you fetch and cache the CRL
140
+ evidenceFromRevocation(await checkRevocation({ crlDer: await myCrl(revocation.crl_urls), serial: revocation.serial_number,
141
+ issuerCertificate: revocation.issuer_certificate!, pastNextUpdate: "refuse", now: new Date() })),
142
+ // freshness: { ... }, // optional, SPEC section 18 (freshnessPolicy): check() also runs checkFreshAction
143
+ issuerKeys: { "https://kya-br-agents.marcelfons.workers.dev": issuerJwk }, // optional; others are refused
144
+ issuerFallback: false // optional: true sends requests without the credential to the issuer
145
+ }
146
+ });
147
+ ```
148
+
149
+ Headers: `KYA-Credential-Document` (base64url of the credential's JSON) and `KYA-Principal-Proof`, plus
150
+ `KYA-Delegation` for a sub-agent. The middleware verifies the request signature (agent or last delegate), the
151
+ proof (`verifySelfIssuedCredential`, or `verifyPrincipalProof` and the issuer's attestation), the delegation
152
+ chain at your clock, and the certificate's status; `kya.offline` holds the evidence, and `check()` / `allowed()`
153
+ evaluate every mandate locally (`allowed()` returns an unsigned local verdict). New errors:
154
+ `KYA_CREDENTIAL_MISSING`, `KYA_CREDENTIAL_MALFORMED`, `KYA_CREDENTIAL_MISMATCH`, `KYA_PRINCIPAL_PROOF_MISSING`,
155
+ `KYA_PRINCIPAL_PROOF_MALFORMED`, `KYA_PRINCIPAL_PROOF_INVALID`, `KYA_ISSUER_NOT_TRUSTED`,
156
+ `KYA_CREDENTIAL_SIGNATURE_INVALID`, `KYA_CERTIFICATE_REVOKED`, `KYA_CERTIFICATE_STATUS_UNKNOWN`,
157
+ `KYA_CERTIFICATE_STATUS_STALE` (403), `KYA_CERTIFICATE_STATUS_UNAVAILABLE` (502), `KYA_CLOCK_INVALID` (502).
158
+ What it cannot see: a revocation or suspension made only at an issuer; daily ceilings need your `spent_today`.
159
+ The headers carry the principal's certificate (personal data for an e-CPF): treat them as such.
160
+
103
161
  ## Person: an agent that acts for you (e-CPF)
104
162
 
105
163
  ```ts
package/dist/auth.d.ts CHANGED
@@ -1,11 +1,22 @@
1
- import { type ActionQuery, type AgentKey, type CredentialStatus, type KyaClient, type Mandate, type Verdict, type Verification, type KyaOptions } from "./index.js";
1
+ import { type Delegation, type DelegationAnchor, type FreshnessVerdict, type PrincipalProof, type StatusEvidence } from "@kya-brasil/core";
2
+ import { type ActionQuery, type AgentKey, type Credential, type CredentialStatus, type KyaClient, type Mandate, type Verdict, type Verification, type KyaOptions } from "./index.js";
3
+ import { type KyaOfflineOptions } from "./offline.js";
4
+ export { type CertificateStatusQuery, type KyaOfflineOptions, decodeCredentialHeader, encodeCredentialHeader } from "./offline.js";
5
+ /**
6
+ * Request headers. `credentialDocument` and `principalProof` are sent only to verifiers that work without the
7
+ * issuer (offline option): the credential's JSON and the principal's proof, both base64url.
8
+ */
2
9
  export declare const KYA_HEADERS: {
3
10
  readonly credential: "kya-credential";
4
11
  readonly created: "kya-created";
5
12
  readonly expires: "kya-expires";
6
13
  readonly signature: "kya-signature";
7
14
  readonly content: "kya-content-sha256";
15
+ readonly delegation: "kya-delegation";
16
+ readonly credentialDocument: "kya-credential-document";
17
+ readonly principalProof: "kya-principal-proof";
8
18
  };
19
+ export type { Delegation } from "@kya-brasil/core";
9
20
  export declare function sha256Hex(data: string | ArrayBuffer | Uint8Array): Promise<string>;
10
21
  /** Canonical URL for the signature: origin (lowercase host) + path + query, no fragment. */
11
22
  export declare function canonicalUrl(url: string): string;
@@ -16,6 +27,7 @@ export declare function requestText(p: {
16
27
  created: number;
17
28
  expires: number;
18
29
  contentSha256?: string | null;
30
+ delegationSha256?: string | null;
19
31
  }): string;
20
32
  export type SigningKey = {
21
33
  kind: "evm" | "erc8004";
@@ -24,7 +36,11 @@ export type SigningKey = {
24
36
  kind: "web-bot-auth";
25
37
  privateKey: CryptoKey | string;
26
38
  };
27
- /** Signs a request with the agent key and returns the KYA headers to send with it. */
39
+ /**
40
+ * Signs a request with the agent key and returns the KYA headers to send with it. A sub-agent passes its
41
+ * `delegation` chain (from signDelegation) and signs with its own key. `present` adds the credential and the
42
+ * principal's proof (KYA-Credential-Document, KYA-Principal-Proof) for verifiers that work without the issuer.
43
+ */
28
44
  export declare function signRequest(p: {
29
45
  credentialId: string;
30
46
  key: SigningKey;
@@ -33,7 +49,32 @@ export declare function signRequest(p: {
33
49
  body?: string | ArrayBuffer | Uint8Array | null;
34
50
  ttlSeconds?: number;
35
51
  now?: Date;
52
+ delegation?: Delegation[];
53
+ present?: {
54
+ credential: Credential | object;
55
+ proof: PrincipalProof;
56
+ };
36
57
  }): Promise<Record<string, string>>;
58
+ /** The key a signing key verifies as, in the form a credential or a delegation names it. */
59
+ export declare function agentKeyOf(key: SigningKey): Promise<AgentKey>;
60
+ /**
61
+ * Delegation to a sub-agent (D-30): hands `delegate` a mandate contained in the delegator's, for `ttlSeconds`
62
+ * (at most the mandate's `delegation.max_ttl_seconds`). `key` is the credential's agent key, or, to delegate
63
+ * again, the key of the last delegate in `chain`. Returns the new chain; the sub-agent sends it with
64
+ * signRequest({ delegation }). Nothing goes to the issuer.
65
+ */
66
+ export declare function signDelegation(p: {
67
+ credential: DelegationAnchor | Credential;
68
+ key: SigningKey;
69
+ delegate: {
70
+ kind: "evm" | "web-bot-auth";
71
+ id: string;
72
+ };
73
+ mandate: Mandate;
74
+ ttlSeconds: number;
75
+ chain?: Delegation[];
76
+ now?: Date;
77
+ }): Promise<Delegation[]>;
37
78
  export interface KyaAuthOptions extends KyaOptions {
38
79
  /** How long a fetched credential is reused before asking the issuer again (ms). Default 60 000. */
39
80
  cacheTtlMs?: number;
@@ -43,6 +84,12 @@ export interface KyaAuthOptions extends KyaOptions {
43
84
  maxTtlSeconds?: number;
44
85
  /** Verify the body hash when the request has a body. Default true. */
45
86
  requireBodyHash?: boolean;
87
+ /**
88
+ * Opt-in: accept requests that carry their own credential and principal's proof, with no issuer in the path
89
+ * (SPEC sections 14, 18-20). Trust anchors, clock and certificate status are required (construction throws
90
+ * without them); see KyaOfflineOptions. Absent: behaviour unchanged.
91
+ */
92
+ offline?: KyaOfflineOptions;
46
93
  }
47
94
  export interface KyaContext {
48
95
  credential_id: string;
@@ -51,13 +98,39 @@ export interface KyaContext {
51
98
  status: CredentialStatus;
52
99
  mandate: Mandate;
53
100
  credential: Verification["credential"];
54
- /** Which key verified the request signature. */
101
+ /** Which key verified the request signature (the sub-agent's key on a delegated request). */
55
102
  signer: string;
103
+ /**
104
+ * D-30: present when a sub-agent signed the request under a delegation chain. `agent` above stays the
105
+ * credential's agent; `delegate` is the sub-agent; every mandate in `mandates` must allow an action.
106
+ */
107
+ delegation: {
108
+ depth: number;
109
+ delegate: AgentKey;
110
+ expires_at: string;
111
+ chain: Delegation[];
112
+ mandates: Mandate[];
113
+ } | null;
56
114
  /** Whether the body hash was checked ("verified"), absent from the signature ("none") or not checkable ("skipped"). */
57
115
  body: "verified" | "none" | "skipped";
58
- /** Local evaluation of the mandate (no daily ceilings; those need the issuer). */
116
+ /**
117
+ * Present when the request was verified without the issuer (`offline` option): the credential came with the
118
+ * request, the principal's proof verified up to your anchors, and `evidence` is what your certificateStatus
119
+ * returned. check() and allowed() are then local, at your clock (allowed() never calls the issuer).
120
+ */
121
+ offline?: {
122
+ issuer: string;
123
+ self_issued: boolean;
124
+ verified_at: string;
125
+ evidence: StatusEvidence[];
126
+ };
127
+ /** Local evaluation of the mandate (no daily ceilings; those need the issuer). On a delegated request, of every mandate in the chain. */
59
128
  check(q: ActionQuery): LocalVerdict;
60
- /** Signed verdict from the issuer ($0.01, needs privateKey or internalKey in the options). */
129
+ /**
130
+ * Signed verdict from the issuer ($0.01, needs privateKey or internalKey in the options). The issuer evaluates
131
+ * the credential's mandate; on a delegated request the delegated mandates are evaluated here first, and when
132
+ * one refuses the answer is that local refusal (unsigned, `delegation_level` set, the issuer is not called).
133
+ */
61
134
  allowed(q: ActionQuery): Promise<Verdict>;
62
135
  }
63
136
  export type KyaAuthResult = {
@@ -77,6 +150,8 @@ export interface LocalVerdict {
77
150
  detail: string;
78
151
  }[];
79
152
  not_evaluated: string[];
153
+ /** Offline mode with a freshness policy: the status freshness verdict (SPEC section 18, checkFreshAction). */
154
+ freshness?: FreshnessVerdict;
80
155
  }
81
156
  /**
82
157
  * Local evaluation of the reserved mandate fields: actions, max_per_operation, currencies, counterparties
@@ -84,6 +159,10 @@ export interface LocalVerdict {
84
159
  * max_per_day when you pass spent_today (nobody else counts it: the issuer keeps no record of acts).
85
160
  */
86
161
  export declare function checkMandate(mandate: Mandate, q: ActionQuery, now?: Date): LocalVerdict;
162
+ /** checkMandate over every mandate of a delegation chain (credential first): allowed only when all allow it. */
163
+ export declare function checkMandates(mandates: Mandate[], q: ActionQuery, now?: Date): LocalVerdict & {
164
+ levels: LocalVerdict[];
165
+ };
87
166
  export interface KyaAuth {
88
167
  client: KyaClient;
89
168
  /** Authenticate any Web-standard Request. Never throws; read `ok`. */
package/dist/auth.js CHANGED
@@ -16,9 +16,14 @@
16
16
  * const headers = await signRequest({ credentialId, key: { kind: "evm", privateKey }, method: "POST", url, body });
17
17
  * await fetch(url, { method: "POST", headers: { ...headers, "content-type": "application/json" }, body });
18
18
  *
19
- * Request text (what the agent signs; the profile is in docs/SPEC.md section 15):
19
+ * Sub-agent (D-30, the credential's mandate allows delegation):
20
+ * const chain = await signDelegation({ credential, key: parentKey, delegate: { kind: "evm", id: sub }, mandate: { actions: ["quote"] }, ttlSeconds: 600 });
21
+ * const headers = await signRequest({ credentialId, key: subKey, delegation: chain, method: "GET", url });
22
+ *
23
+ * Request text (what the agent signs; the profile is in docs/SPEC.md section 14):
20
24
  * KYA Brasil: request
21
25
  * credential: <credential_id>
26
+ * delegation: <sha256 hex of the KYA-Delegation header> (only on delegated requests)
22
27
  * method: <METHOD>
23
28
  * url: <origin + path + query, lowercase host>
24
29
  * created: <unix seconds>
@@ -26,10 +31,17 @@
26
31
  * content-sha256: <hex of the body | ->
27
32
  */
28
33
  import { verifyMessage } from "viem";
34
+ import { SELF_ISSUER, checkFreshAction, decodePrincipalProofHeader, encodeDelegationHeader, encodePrincipalProofHeader, decodeDelegationHeader, prepareDelegation, verifyDelegationChain } from "@kya-brasil/core";
29
35
  import { privateKeyToAccount } from "viem/accounts";
30
36
  import { createKyaClient } from "./index.js";
31
37
  import { fromBase64Url, toBase64Url } from "./base64url.js";
32
- export const KYA_HEADERS = { credential: "kya-credential", created: "kya-created", expires: "kya-expires", signature: "kya-signature", content: "kya-content-sha256" };
38
+ import { certificateStatusOf, decodeCredentialHeader, encodeCredentialHeader, resolveOffline, verifyPresentedCredential } from "./offline.js";
39
+ export { decodeCredentialHeader, encodeCredentialHeader } from "./offline.js";
40
+ /**
41
+ * Request headers. `credentialDocument` and `principalProof` are sent only to verifiers that work without the
42
+ * issuer (offline option): the credential's JSON and the principal's proof, both base64url.
43
+ */
44
+ export const KYA_HEADERS = { credential: "kya-credential", created: "kya-created", expires: "kya-expires", signature: "kya-signature", content: "kya-content-sha256", delegation: "kya-delegation", credentialDocument: "kya-credential-document", principalProof: "kya-principal-proof" };
33
45
  const enc = new TextEncoder();
34
46
  const hex = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
35
47
  const b64u = toBase64Url;
@@ -47,6 +59,7 @@ export function requestText(p) {
47
59
  return [
48
60
  "KYA Brasil: request",
49
61
  `credential: ${p.credentialId}`,
62
+ ...(p.delegationSha256 ? [`delegation: ${p.delegationSha256}`] : []),
50
63
  `method: ${p.method.toUpperCase()}`,
51
64
  `url: ${canonicalUrl(p.url)}`,
52
65
  `created: ${p.created}`,
@@ -54,26 +67,62 @@ export function requestText(p) {
54
67
  `content-sha256: ${p.contentSha256 ?? "-"}`
55
68
  ].join("\n");
56
69
  }
57
- /** Signs a request with the agent key and returns the KYA headers to send with it. */
70
+ // --- Agent side -------------------------------------------------------------------------------
71
+ const importPkcs8 = (pem, extractable = false) => crypto.subtle.importKey("pkcs8", unb64u(pem.replace(/-----[^-]+-----/g, "").replace(/\s+/g, "")), { name: "Ed25519" }, extractable, ["sign"]);
72
+ async function signText(key, text) {
73
+ if (key.kind === "web-bot-auth") {
74
+ const k = typeof key.privateKey === "string" ? await importPkcs8(key.privateKey) : key.privateKey;
75
+ return b64u(new Uint8Array(await crypto.subtle.sign({ name: "Ed25519" }, k, enc.encode(text))));
76
+ }
77
+ return privateKeyToAccount(key.privateKey).signMessage({ message: text });
78
+ }
79
+ /**
80
+ * Signs a request with the agent key and returns the KYA headers to send with it. A sub-agent passes its
81
+ * `delegation` chain (from signDelegation) and signs with its own key. `present` adds the credential and the
82
+ * principal's proof (KYA-Credential-Document, KYA-Principal-Proof) for verifiers that work without the issuer.
83
+ */
58
84
  export async function signRequest(p) {
59
85
  const created = Math.floor((p.now ?? new Date()).getTime() / 1000);
60
86
  const expires = created + (p.ttlSeconds ?? 60);
61
87
  const contentSha256 = p.body !== undefined && p.body !== null && (typeof p.body !== "string" || p.body.length > 0) ? await sha256Hex(p.body) : null;
62
- const text = requestText({ credentialId: p.credentialId, method: p.method, url: p.url, created, expires, contentSha256 });
63
- let signature;
64
- if (p.key.kind === "web-bot-auth") {
65
- const key = typeof p.key.privateKey === "string" ? await crypto.subtle.importKey("pkcs8", unb64u(p.key.privateKey.replace(/-----[^-]+-----/g, "").replace(/\s+/g, "")), { name: "Ed25519" }, false, ["sign"]) : p.key.privateKey;
66
- signature = b64u(new Uint8Array(await crypto.subtle.sign({ name: "Ed25519" }, key, enc.encode(text))));
67
- }
68
- else {
69
- signature = await privateKeyToAccount(p.key.privateKey).signMessage({ message: text });
70
- }
88
+ const delegationHeader = p.delegation && p.delegation.length ? encodeDelegationHeader(p.delegation) : null;
89
+ const delegationSha256 = delegationHeader ? await sha256Hex(delegationHeader) : null;
90
+ const text = requestText({ credentialId: p.credentialId, method: p.method, url: p.url, created, expires, contentSha256, delegationSha256 });
91
+ const signature = await signText(p.key, text);
71
92
  const headers = { [KYA_HEADERS.credential]: p.credentialId, [KYA_HEADERS.created]: String(created), [KYA_HEADERS.expires]: String(expires), [KYA_HEADERS.signature]: signature };
72
93
  if (contentSha256)
73
94
  headers[KYA_HEADERS.content] = contentSha256;
95
+ if (delegationHeader)
96
+ headers[KYA_HEADERS.delegation] = delegationHeader;
97
+ if (p.present) {
98
+ headers[KYA_HEADERS.credentialDocument] = encodeCredentialHeader(p.present.credential);
99
+ headers[KYA_HEADERS.principalProof] = encodePrincipalProofHeader(p.present.proof);
100
+ }
74
101
  return headers;
75
102
  }
76
- const RESERVED = ["actions", "max_per_operation", "max_per_day", "currencies", "counterparties", "payment_instruments", "valid_from", "valid_until"];
103
+ /** The key a signing key verifies as, in the form a credential or a delegation names it. */
104
+ export async function agentKeyOf(key) {
105
+ if (key.kind !== "web-bot-auth")
106
+ return { kind: "evm", id: privateKeyToAccount(key.privateKey).address };
107
+ const k = typeof key.privateKey === "string" ? await importPkcs8(key.privateKey, true) : key.privateKey;
108
+ const jwk = (await crypto.subtle.exportKey("jwk", k));
109
+ if (!jwk.x)
110
+ throw new Error("cannot derive the Ed25519 public key from this private key");
111
+ return { kind: "web-bot-auth", id: jwk.x };
112
+ }
113
+ /**
114
+ * Delegation to a sub-agent (D-30): hands `delegate` a mandate contained in the delegator's, for `ttlSeconds`
115
+ * (at most the mandate's `delegation.max_ttl_seconds`). `key` is the credential's agent key, or, to delegate
116
+ * again, the key of the last delegate in `chain`. Returns the new chain; the sub-agent sends it with
117
+ * signRequest({ delegation }). Nothing goes to the issuer.
118
+ */
119
+ export async function signDelegation(p) {
120
+ const chain = p.chain ?? [];
121
+ const { delegation, text } = await prepareDelegation({ credential: p.credential, chain, delegate: p.delegate, mandate: p.mandate, ttlSeconds: p.ttlSeconds, now: p.now });
122
+ const signature = await signText(p.key, text);
123
+ return [...chain, { ...delegation, signature }];
124
+ }
125
+ const RESERVED = ["actions", "max_per_operation", "max_per_day", "currencies", "counterparties", "payment_instruments", "valid_from", "valid_until", "delegation"];
77
126
  function normalizeCounterparty(c) {
78
127
  const t = c.trim();
79
128
  const digits = t.replace(/\D/g, "");
@@ -133,6 +182,17 @@ export function checkMandate(mandate, q, now = new Date()) {
133
182
  not_evaluated.push("max_per_day (pass spent_today: the issuer keeps no record of acts)");
134
183
  return { allowed: checks.every((c) => c.ok), checks, not_evaluated };
135
184
  }
185
+ /** checkMandate over every mandate of a delegation chain (credential first): allowed only when all allow it. */
186
+ export function checkMandates(mandates, q, now = new Date()) {
187
+ const levels = mandates.map((m) => checkMandate(m, q, now));
188
+ const prefix = (i, s) => (i === 0 ? s : `delegation ${i}: ${s}`);
189
+ return {
190
+ allowed: levels.length > 0 && levels.every((l) => l.allowed),
191
+ checks: levels.flatMap((l, i) => l.checks.map((c) => ({ ...c, rule: prefix(i, c.rule) }))),
192
+ not_evaluated: [...new Set(levels.flatMap((l, i) => l.not_evaluated.map((n) => prefix(i, n))))],
193
+ levels
194
+ };
195
+ }
136
196
  const headerOf = (headers, name) => {
137
197
  if (headers instanceof Headers)
138
198
  return headers.get(name);
@@ -140,6 +200,8 @@ const headerOf = (headers, name) => {
140
200
  return Array.isArray(v) ? v[0] ?? null : v ?? null;
141
201
  };
142
202
  export function createKyaAuth(opts = {}) {
203
+ // Offline mode is opt-in and validated here: missing anchors, clock or certificate status throws.
204
+ const offline = opts.offline === undefined ? null : resolveOffline(opts.offline);
143
205
  const client = createKyaClient(opts);
144
206
  const cacheTtl = opts.cacheTtlMs ?? 60_000;
145
207
  const maxSkew = opts.maxSkewSeconds ?? 300;
@@ -171,15 +233,26 @@ export function createKyaAuth(opts = {}) {
171
233
  }
172
234
  }
173
235
  const fail = (status, error, message) => ({ ok: false, status, error, message });
236
+ /** The offline clock, checked on every use (a wrong clock makes stale evidence look fresh). */
237
+ function offlineNow() {
238
+ const d = offline?.now();
239
+ return d instanceof Date && !Number.isNaN(d.getTime()) ? d : null;
240
+ }
174
241
  async function authenticateParts(p) {
175
242
  const credentialId = headerOf(p.headers, KYA_HEADERS.credential);
176
243
  const created = Number(headerOf(p.headers, KYA_HEADERS.created));
177
244
  const expires = Number(headerOf(p.headers, KYA_HEADERS.expires));
178
245
  const signature = headerOf(p.headers, KYA_HEADERS.signature);
179
246
  const contentHeader = headerOf(p.headers, KYA_HEADERS.content);
247
+ const delegationHeader = headerOf(p.headers, KYA_HEADERS.delegation);
248
+ const document = offline ? headerOf(p.headers, KYA_HEADERS.credentialDocument) : null;
249
+ const useOffline = offline !== null && (document !== null || !offline.issuerFallback);
180
250
  if (!credentialId || !signature || !Number.isFinite(created) || !Number.isFinite(expires))
181
251
  return fail(401, "KYA_HEADERS_MISSING", `send ${KYA_HEADERS.credential}, ${KYA_HEADERS.created}, ${KYA_HEADERS.expires} and ${KYA_HEADERS.signature}`);
182
- const now = Math.floor(Date.now() / 1000);
252
+ const at = useOffline ? offlineNow() : new Date();
253
+ if (!at)
254
+ return fail(502, "KYA_CLOCK_INVALID", "the verifier's clock (offline.now) did not return a valid Date");
255
+ const now = Math.floor(at.getTime() / 1000);
183
256
  if (created > now + maxSkew)
184
257
  return fail(401, "KYA_SIGNATURE_NOT_YET_VALID", "created is in the future");
185
258
  if (expires < now)
@@ -200,6 +273,10 @@ export function createKyaAuth(opts = {}) {
200
273
  }
201
274
  else if (contentHeader)
202
275
  bodyState = "skipped";
276
+ const delegationSha256 = delegationHeader ? await sha256Hex(delegationHeader) : null;
277
+ const text = requestText({ credentialId, method: p.method, url: p.url, created, expires, contentSha256: contentHeader, delegationSha256 });
278
+ if (useOffline)
279
+ return authenticateOffline({ credentialId, document, proofHeader: headerOf(p.headers, KYA_HEADERS.principalProof), delegationHeader, text, signature, bodyState, at });
203
280
  let v;
204
281
  try {
205
282
  v = await verification(credentialId);
@@ -208,16 +285,106 @@ export function createKyaAuth(opts = {}) {
208
285
  const status = e.status;
209
286
  return status === 404 ? fail(401, "KYA_CREDENTIAL_UNKNOWN", "unknown credential_id") : fail(502, "KYA_ISSUER_UNAVAILABLE", e.message);
210
287
  }
211
- const text = requestText({ credentialId, method: p.method, url: p.url, created, expires, contentSha256: contentHeader });
212
- return finish(credentialId, v, text, signature, bodyState);
288
+ let delegation = null;
289
+ if (delegationHeader) {
290
+ if (!v.credential)
291
+ return fail(403, "KYA_CREDENTIAL_NOT_ACTIVE", `credential is ${v.status}`);
292
+ const d = await delegationOf(anchorOf(credentialId, v.agent, v.credential), delegationHeader);
293
+ if (!d.ok)
294
+ return d.result;
295
+ delegation = d.delegation;
296
+ }
297
+ return finish(credentialId, v, text, signature, bodyState, delegation);
298
+ }
299
+ const anchorOf = (credentialId, agent, credential) => ({ credential_id: credentialId, agent: agent, mandate: credential.mandate, expires_at: credential.expires_at });
300
+ /** D-30: verifies a KYA-Delegation chain against the credential (offline, core rules). */
301
+ async function delegationOf(anchor, header, now) {
302
+ const no = (status, error, message) => ({ ok: false, result: fail(status, error, message) });
303
+ let chain;
304
+ try {
305
+ chain = decodeDelegationHeader(header);
306
+ }
307
+ catch (e) {
308
+ return no(401, "KYA_DELEGATION_MALFORMED", e.message);
309
+ }
310
+ const r = await verifyDelegationChain({ credential: anchor, chain, now });
311
+ if (!r.ok)
312
+ return no(r.error === "KYA_DELEGATION_NOT_ALLOWED" || r.error === "KYA_DELEGATION_BROADER" ? 403 : 401, r.error, r.message);
313
+ return { ok: true, delegation: { depth: r.depth, delegate: r.delegate, expires_at: r.expires_at, chain: chain, mandates: r.mandates } };
314
+ }
315
+ /**
316
+ * Offline (opt-in): the credential and the principal's proof came with the request. Order: shape, delegation
317
+ * chain and request signature (cheap, reject strangers before anything else), then the principal's proof up to
318
+ * the anchors, then the caller's certificate status. No network here.
319
+ */
320
+ async function authenticateOffline(p) {
321
+ const o = offline;
322
+ if (!p.document)
323
+ return fail(401, "KYA_CREDENTIAL_MISSING", `this verifier works without the issuer: send ${KYA_HEADERS.credentialDocument} and ${KYA_HEADERS.principalProof}`);
324
+ let credential;
325
+ try {
326
+ credential = decodeCredentialHeader(p.document);
327
+ }
328
+ catch (e) {
329
+ return fail(401, "KYA_CREDENTIAL_MALFORMED", e.message);
330
+ }
331
+ if (!credential.agent || typeof credential.agent !== "object" || typeof credential.agent.id !== "string" || !credential.mandate || typeof credential.mandate !== "object" || typeof credential.expires_at !== "string" || !credential.principal || !credential.authorized_by)
332
+ return fail(401, "KYA_CREDENTIAL_MALFORMED", "KYA-Credential-Document is not a credential");
333
+ if (credential.credential_id !== p.credentialId)
334
+ return fail(401, "KYA_CREDENTIAL_MISMATCH", `${KYA_HEADERS.credentialDocument} is credential ${String(credential.credential_id)}, the request names ${p.credentialId}`);
335
+ if (!p.proofHeader)
336
+ return fail(401, "KYA_PRINCIPAL_PROOF_MISSING", `send ${KYA_HEADERS.principalProof} with ${KYA_HEADERS.credentialDocument}`);
337
+ let proof;
338
+ try {
339
+ proof = decodePrincipalProofHeader(p.proofHeader);
340
+ }
341
+ catch (e) {
342
+ return fail(401, "KYA_PRINCIPAL_PROOF_MALFORMED", e.message);
343
+ }
344
+ let delegation = null;
345
+ if (p.delegationHeader) {
346
+ const d = await delegationOf(anchorOf(p.credentialId, credential.agent, credential), p.delegationHeader, p.at);
347
+ if (!d.ok)
348
+ return d.result;
349
+ delegation = d.delegation;
350
+ }
351
+ const sig = await verifySignature(delegation ? delegation.delegate : credential.agent, p.text, p.signature);
352
+ if (!sig.ok)
353
+ return fail(401, "KYA_BAD_SIGNATURE", delegation ? "signature does not match the delegate's key over the signed text" : "signature does not match the credential's agent key over the signed text");
354
+ const presented = await verifyPresentedCredential(o, credential, proof, p.at);
355
+ if (!presented.ok)
356
+ return fail(presented.status, presented.error, presented.message);
357
+ const status = await certificateStatusOf(o, { credential, revocation: presented.revocation }, p.at);
358
+ if (!status.ok)
359
+ return fail(status.status, status.error, status.message);
360
+ const v = { credential_id: p.credentialId, status: "active", status_reason: null, agent: credential.agent, principal: credential.principal, credential, events: [] };
361
+ const info = { issuer: credential.issuer, self_issued: credential.issuer === SELF_ISSUER, verified_at: p.at.toISOString(), evidence: status.evidence };
362
+ return { ok: true, kya: contextOf(p.credentialId, v, sig.signer, p.bodyState, delegation, info) };
213
363
  }
214
- async function finish(credentialId, v, text, signature, bodyState) {
215
- const sig = await verifySignature(v.agent, text, signature);
364
+ async function finish(credentialId, v, text, signature, bodyState, delegation = null) {
365
+ const sig = await verifySignature(delegation ? delegation.delegate : v.agent, text, signature);
216
366
  if (!sig.ok)
217
- return fail(401, "KYA_BAD_SIGNATURE", "signature does not match the credential's agent key over the signed text");
367
+ return fail(401, "KYA_BAD_SIGNATURE", delegation ? "signature does not match the delegate's key over the signed text" : "signature does not match the credential's agent key over the signed text");
218
368
  if (v.status !== "active")
219
369
  return fail(403, "KYA_CREDENTIAL_NOT_ACTIVE", `credential is ${v.status}${v.status_reason ? `: ${v.status_reason}` : ""}`);
370
+ return { ok: true, kya: contextOf(credentialId, v, sig.signer, bodyState, delegation, null) };
371
+ }
372
+ function contextOf(credentialId, v, signer, bodyState, delegation, local) {
220
373
  const mandate = v.credential?.mandate ?? {};
374
+ const mandates = delegation ? delegation.mandates : [mandate];
375
+ // Offline: every mandate at the verifier's clock, and, with a freshness policy, checkFreshAction on the evidence.
376
+ const offlineCheck = (q) => {
377
+ const at = offlineNow();
378
+ if (!at)
379
+ return { allowed: false, checks: [{ rule: "clock", ok: false, detail: "the verifier's clock (offline.now) did not return a valid Date" }], not_evaluated: [] };
380
+ const base = delegation ? checkMandates(mandates, q, at) : checkMandate(mandate, q, at);
381
+ const policy = offline.policy;
382
+ if (!policy || !local)
383
+ return base;
384
+ const f = checkFreshAction(mandate, { ...q, now: at }, { policy, evidence: local.evidence });
385
+ const freshness = f.freshness.checks.map((c) => ({ rule: `freshness (${c.kind})`, ok: c.ok, detail: c.detail }));
386
+ return { allowed: base.allowed && f.allowed, checks: [...base.checks, ...freshness], not_evaluated: base.not_evaluated, freshness: f.freshness };
387
+ };
221
388
  const ctx = {
222
389
  credential_id: credentialId,
223
390
  agent: v.agent,
@@ -225,15 +392,34 @@ export function createKyaAuth(opts = {}) {
225
392
  status: v.status,
226
393
  mandate,
227
394
  credential: v.credential,
228
- signer: sig.signer,
395
+ signer,
229
396
  body: bodyState,
230
- check: (q) => checkMandate(mandate, q),
231
- allowed: (q) => client.allowed(credentialId, q)
397
+ delegation,
398
+ ...(local ? { offline: local } : {}),
399
+ check: (q) => (local ? offlineCheck(q) : delegation ? checkMandates(mandates, q) : checkMandate(mandate, q)),
400
+ allowed: async (q) => {
401
+ if (local) {
402
+ // No issuer to ask: the local verdict, unsigned, in the issuer's shape.
403
+ const l = offlineCheck(q);
404
+ return { credential_id: credentialId, status: v.status, allowed: l.allowed, reason: l.allowed ? "inside every mandate (verified offline)" : "refused by the mandate or the status freshness (verified offline)", checks: l.checks.map((c) => ({ field: c.rule, ok: c.ok, detail: c.detail })), not_evaluated: l.not_evaluated, offline: true, ...(l.freshness ? { freshness: l.freshness } : {}) };
405
+ }
406
+ if (delegation) {
407
+ for (let i = 1; i < mandates.length; i++) {
408
+ const localVerdict = checkMandate(mandates[i], q);
409
+ if (!localVerdict.allowed) {
410
+ return { credential_id: credentialId, status: v.status, allowed: false, reason: `outside the mandate of delegation ${i}`, checks: localVerdict.checks.map((c) => ({ field: c.rule, ok: c.ok, detail: c.detail })), not_evaluated: localVerdict.not_evaluated, delegation_level: i };
411
+ }
412
+ }
413
+ }
414
+ return client.allowed(credentialId, q);
415
+ }
232
416
  };
233
- return { ok: true, kya: ctx };
417
+ return ctx;
234
418
  }
235
419
  /** Low level: verify a signature by a credential's agent key over a text you built yourself (used for signed responses). */
236
420
  async function verifySigned(p) {
421
+ if (offline && !offline.issuerFallback)
422
+ return fail(401, "KYA_CREDENTIAL_MISSING", "this verifier works without the issuer: a bare signature carries no credential to check");
237
423
  let v;
238
424
  try {
239
425
  v = await verification(p.credentialId);
@@ -309,13 +495,6 @@ export const KYA_REPLY_HEADER = "kya-in-reply-to";
309
495
  export function responseText(p) {
310
496
  return ["KYA Brasil: response", `credential: ${p.credentialId}`, `in-reply-to: ${p.inReplyTo}`, `created: ${p.created}`, `expires: ${p.expires}`, `content-sha256: ${p.contentSha256 ?? "-"}`].join("\n");
311
497
  }
312
- async function signText(key, text) {
313
- if (key.kind === "web-bot-auth") {
314
- const k = typeof key.privateKey === "string" ? await crypto.subtle.importKey("pkcs8", unb64u(key.privateKey.replace(/-----[^-]+-----/g, "").replace(/\s+/g, "")), { name: "Ed25519" }, false, ["sign"]) : key.privateKey;
315
- return b64u(new Uint8Array(await crypto.subtle.sign({ name: "Ed25519" }, k, enc.encode(text))));
316
- }
317
- return privateKeyToAccount(key.privateKey).signMessage({ message: text });
318
- }
319
498
  /** Callee side: headers that prove who answered, bound to the request signature it replies to. */
320
499
  export async function signResponse(p) {
321
500
  const created = Math.floor((p.now ?? new Date()).getTime() / 1000);
package/dist/index.d.ts CHANGED
@@ -33,6 +33,11 @@ export interface Mandate {
33
33
  ref_sha256: string;
34
34
  label?: string;
35
35
  }[];
36
+ /** D-30: the agent may hand a narrower mandate to sub-agents (signDelegation), up to max_depth levels below it. */
37
+ delegation?: {
38
+ max_depth: number;
39
+ max_ttl_seconds: number;
40
+ };
36
41
  valid_from?: string;
37
42
  valid_until?: string;
38
43
  [k: string]: unknown;
@@ -408,6 +413,7 @@ export declare function verifyCredentialOffline(credential: Credential, opts?: {
408
413
  }): Promise<OfflineCheck>;
409
414
  export * from "./auth.js";
410
415
  export { isValidCpf, personAgentsText } from "@kya-brasil/core";
416
+ export { checkDelegatedAction, decodeDelegationHeader, encodeDelegationHeader, isMandateSubset, mandateSubsetProblems, verifyDelegationChain } from "@kya-brasil/core";
411
417
  export { buildDetachedCms, cnpjFromCommonName, ecnpjSigner, openPfx, readSigningLink, signWithECnpjPfx, type ECnpjSigner, type RawSigner } from "@kya-brasil/core";
412
418
  export { listTokenCertificates, openToken, type TokenCertificate, type TokenOptions } from "./pkcs11.js";
413
419
  /**
package/dist/index.js CHANGED
@@ -255,6 +255,8 @@ export async function verifyCredentialOffline(credential, opts = {}) {
255
255
  export * from "./auth.js";
256
256
  // e-CNPJ signing (D-26, D-27): the implementation lives in @kya-brasil/core, shared with anyone using the library.
257
257
  export { isValidCpf, personAgentsText } from "@kya-brasil/core";
258
+ // Delegation to sub-agents (D-30): chain rules live in the core, signing and the middleware in ./auth.
259
+ export { checkDelegatedAction, decodeDelegationHeader, encodeDelegationHeader, isMandateSubset, mandateSubsetProblems, verifyDelegationChain } from "@kya-brasil/core";
258
260
  export { buildDetachedCms, cnpjFromCommonName, ecnpjSigner, openPfx, readSigningLink, signWithECnpjPfx } from "@kya-brasil/core";
259
261
  export { listTokenCertificates, openToken } from "./pkcs11.js";
260
262
  import { signingLink as coreSigningLink } from "@kya-brasil/core";
@@ -0,0 +1,87 @@
1
+ /**
2
+ * "Sign in with KYA" without the issuer (opt-in): the agent presents its credential and the principal's proof with
3
+ * the request, and the verifier checks them against ICP-Brasil alone (SPEC sections 14, 18, 19 and 20).
4
+ *
5
+ * KYA-Credential-Document base64url (no padding) of the credential's JSON
6
+ * KYA-Principal-Proof base64url of { text, cms } (section 19, encodePrincipalProofHeader)
7
+ *
8
+ * A credential with `issuer: "urn:kya-brasil:self"` (issued by the principal, section 20) is checked with
9
+ * verifySelfIssuedCredential; any other with verifyPrincipalProof plus the issuer's attestation, verified with a
10
+ * public key the caller lists (`issuerKeys`), so the credential's own fields (id, expiry, status) are not the
11
+ * agent's word. Nothing here fetches: trust anchors, clock and certificate status are the caller's.
12
+ */
13
+ import { type Credential as CoreCredential, type FreshnessPolicy, type FreshnessPolicyInput, type PrincipalProof, type StatusEvidence } from "@kya-brasil/core";
14
+ import type { Credential } from "./index.js";
15
+ /** What the caller's status callback receives: the credential and where to check its certificate (CRL). */
16
+ export interface CertificateStatusQuery {
17
+ credential: Credential;
18
+ /** Serial number, issuing AC certificate (base64 DER, null when the CMS does not carry it) and CRL URLs of the signing certificate. */
19
+ revocation: {
20
+ serial_number: string;
21
+ issuer_certificate: string | null;
22
+ crl_urls: string[];
23
+ };
24
+ }
25
+ export interface KyaOfflineOptions {
26
+ /** ICP-Brasil trust anchors (base64 DER or PEM) the principal's certificate must chain to. Required, non-empty. */
27
+ anchors: string[];
28
+ /** The verifier's clock. Required: it decides validity of the certificate, credential, delegation and request window. */
29
+ now: () => Date;
30
+ /**
31
+ * Status of the principal's certificate, for every offline request. Required. Fetch and cache the CRL here (the
32
+ * middleware never does), e.g. `evidenceFromRevocation(await checkRevocation({ crlDer, serial, issuerCertificate,
33
+ * pastNextUpdate, now }))`. May also return `credential` evidence (evidenceFromSignedStatus) for the freshness
34
+ * policy. Throwing answers 502 KYA_CERTIFICATE_STATUS_UNAVAILABLE.
35
+ */
36
+ certificateStatus: (q: CertificateStatusQuery) => StatusEvidence | StatusEvidence[] | Promise<StatusEvidence | StatusEvidence[]>;
37
+ /**
38
+ * Optional status freshness proportional to the amount (section 18): validated with freshnessPolicy at
39
+ * construction; check() then also runs checkFreshAction with the evidence above and the clock. Without it the
40
+ * freshest certificate evidence only has to be in force and not past its valid_until.
41
+ */
42
+ freshness?: FreshnessPolicyInput;
43
+ /**
44
+ * Issuers whose credentials are accepted offline, by `issuer` value, with the Ed25519 public key that signed the
45
+ * credential (published at <issuer>/.well-known/kya.json). A credential from an issuer not listed here is refused;
46
+ * self-issued credentials need no entry.
47
+ */
48
+ issuerKeys?: Record<string, JsonWebKey>;
49
+ /** A request without KYA-Credential-Document goes to the issuer as before when true; refused when false or absent. */
50
+ issuerFallback?: boolean;
51
+ }
52
+ export interface ResolvedOffline {
53
+ anchors: string[];
54
+ now: () => Date;
55
+ certificateStatus: KyaOfflineOptions["certificateStatus"];
56
+ policy: FreshnessPolicy | null;
57
+ issuerKeys: Record<string, JsonWebKey>;
58
+ issuerFallback: boolean;
59
+ }
60
+ /** Validates the offline options; throws on anything missing (no defaults for risk parameters). */
61
+ export declare function resolveOffline(o: KyaOfflineOptions): ResolvedOffline;
62
+ /** KYA-Credential-Document value: base64url (no padding) of the credential's JSON. */
63
+ export declare function encodeCredentialHeader(credential: Credential | CoreCredential): string;
64
+ /** Parses a KYA-Credential-Document value; throws when it is not base64url JSON of an object. */
65
+ export declare function decodeCredentialHeader(value: string): Credential;
66
+ export type OfflineCredentialResult = {
67
+ ok: true;
68
+ revocation: CertificateStatusQuery["revocation"];
69
+ } | {
70
+ ok: false;
71
+ status: 401 | 403;
72
+ error: string;
73
+ message: string;
74
+ };
75
+ /** Checks the presented credential and proof, without the issuer and without the certificate's status. */
76
+ export declare function verifyPresentedCredential(o: ResolvedOffline, credential: Credential, proof: PrincipalProof, now: Date): Promise<OfflineCredentialResult>;
77
+ export type CertificateStatusResult = {
78
+ ok: true;
79
+ evidence: StatusEvidence[];
80
+ } | {
81
+ ok: false;
82
+ status: 403 | 502;
83
+ error: string;
84
+ message: string;
85
+ };
86
+ /** Asks the caller for the certificate's status and applies it: the freshest certificate evidence must be in force. */
87
+ export declare function certificateStatusOf(o: ResolvedOffline, q: CertificateStatusQuery, now: Date): Promise<CertificateStatusResult>;
@@ -0,0 +1,108 @@
1
+ /**
2
+ * "Sign in with KYA" without the issuer (opt-in): the agent presents its credential and the principal's proof with
3
+ * the request, and the verifier checks them against ICP-Brasil alone (SPEC sections 14, 18, 19 and 20).
4
+ *
5
+ * KYA-Credential-Document base64url (no padding) of the credential's JSON
6
+ * KYA-Principal-Proof base64url of { text, cms } (section 19, encodePrincipalProofHeader)
7
+ *
8
+ * A credential with `issuer: "urn:kya-brasil:self"` (issued by the principal, section 20) is checked with
9
+ * verifySelfIssuedCredential; any other with verifyPrincipalProof plus the issuer's attestation, verified with a
10
+ * public key the caller lists (`issuerKeys`), so the credential's own fields (id, expiry, status) are not the
11
+ * agent's word. Nothing here fetches: trust anchors, clock and certificate status are the caller's.
12
+ */
13
+ import { SELF_ISSUER, effectiveStatus, freshnessPolicy, verifyCredentialSignature, verifyPrincipalProof, verifySelfIssuedCredential } from "@kya-brasil/core";
14
+ import { fromBase64Url, toBase64Url } from "./base64url.js";
15
+ /** Validates the offline options; throws on anything missing (no defaults for risk parameters). */
16
+ export function resolveOffline(o) {
17
+ const no = (why) => {
18
+ throw new Error(`createKyaAuth offline: ${why}`);
19
+ };
20
+ if (!o || typeof o !== "object")
21
+ no("expected an object");
22
+ if (!Array.isArray(o.anchors) || !o.anchors.length || o.anchors.some((a) => typeof a !== "string" || !a.trim()))
23
+ no("anchors (ICP-Brasil trust anchors) are required");
24
+ if (typeof o.now !== "function")
25
+ no("now (the verifier's clock) is required");
26
+ if (typeof o.certificateStatus !== "function")
27
+ no("certificateStatus (the certificate's revocation evidence) is required");
28
+ if (o.issuerKeys !== undefined && (!o.issuerKeys || typeof o.issuerKeys !== "object" || Array.isArray(o.issuerKeys)))
29
+ no("issuerKeys must map issuer -> public JWK");
30
+ if (o.issuerFallback !== undefined && typeof o.issuerFallback !== "boolean")
31
+ no("issuerFallback must be a boolean");
32
+ return { anchors: [...o.anchors], now: o.now, certificateStatus: o.certificateStatus, policy: o.freshness === undefined ? null : freshnessPolicy(o.freshness), issuerKeys: { ...(o.issuerKeys ?? {}) }, issuerFallback: o.issuerFallback ?? false };
33
+ }
34
+ /** KYA-Credential-Document value: base64url (no padding) of the credential's JSON. */
35
+ export function encodeCredentialHeader(credential) {
36
+ return toBase64Url(new TextEncoder().encode(JSON.stringify(credential)));
37
+ }
38
+ /** Parses a KYA-Credential-Document value; throws when it is not base64url JSON of an object. */
39
+ export function decodeCredentialHeader(value) {
40
+ let parsed;
41
+ try {
42
+ parsed = JSON.parse(new TextDecoder().decode(fromBase64Url(value.trim())));
43
+ }
44
+ catch {
45
+ throw new Error("KYA-Credential-Document is not base64url JSON");
46
+ }
47
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
48
+ throw new Error("KYA-Credential-Document must be a JSON object");
49
+ return parsed;
50
+ }
51
+ /** Checks the presented credential and proof, without the issuer and without the certificate's status. */
52
+ export async function verifyPresentedCredential(o, credential, proof, now) {
53
+ const c = credential;
54
+ const failed = (checks) => checks.filter((x) => !x.ok);
55
+ const describe = (checks) => failed(checks).map((x) => `${x.rule}: ${x.detail}`).join("; ");
56
+ if (c.issuer === SELF_ISSUER) {
57
+ const r = await verifySelfIssuedCredential({ credential: c, proof, anchors: o.anchors, now });
58
+ if (r.ok && r.revocation)
59
+ return { ok: true, revocation: r.revocation };
60
+ const bad = failed(r.checks);
61
+ // Only the validity window failed: a sound credential that is not (or no longer) in force.
62
+ if (bad.length && bad.every((x) => x.rule === "validity"))
63
+ return { ok: false, status: 403, error: "KYA_CREDENTIAL_NOT_ACTIVE", message: describe(r.checks) };
64
+ return { ok: false, status: 401, error: "KYA_PRINCIPAL_PROOF_INVALID", message: describe(r.checks) || "proof does not verify" };
65
+ }
66
+ const key = typeof c.issuer === "string" && Object.hasOwn(o.issuerKeys, c.issuer) ? o.issuerKeys[c.issuer] : undefined;
67
+ if (!key)
68
+ return { ok: false, status: 401, error: "KYA_ISSUER_NOT_TRUSTED", message: `credentials from issuer ${String(c.issuer)} are not accepted offline (list its key in issuerKeys)` };
69
+ const att = await verifyCredentialSignature(c, key);
70
+ if (!att.valid)
71
+ return { ok: false, status: 401, error: "KYA_CREDENTIAL_SIGNATURE_INVALID", message: `issuer attestation: ${att.reason ?? "does not verify"}` };
72
+ const r = await verifyPrincipalProof({ credential: c, proof, anchors: o.anchors, now });
73
+ if (!r.ok || !r.revocation)
74
+ return { ok: false, status: 401, error: "KYA_PRINCIPAL_PROOF_INVALID", message: describe(r.checks) || "proof does not verify" };
75
+ const issued = Date.parse(c.issued_at);
76
+ const status = effectiveStatus(c, now);
77
+ if (!Number.isFinite(issued) || issued > now.getTime())
78
+ return { ok: false, status: 403, error: "KYA_CREDENTIAL_NOT_ACTIVE", message: `not yet valid (issued ${c.issued_at})` };
79
+ if (status !== "active")
80
+ return { ok: false, status: 403, error: "KYA_CREDENTIAL_NOT_ACTIVE", message: `credential is ${status}` };
81
+ return { ok: true, revocation: r.revocation };
82
+ }
83
+ /** Asks the caller for the certificate's status and applies it: the freshest certificate evidence must be in force. */
84
+ export async function certificateStatusOf(o, q, now) {
85
+ let evidence;
86
+ try {
87
+ const r = await o.certificateStatus(q);
88
+ evidence = (Array.isArray(r) ? r : [r]).filter((e) => !!e && typeof e === "object");
89
+ }
90
+ catch (e) {
91
+ return { ok: false, status: 502, error: "KYA_CERTIFICATE_STATUS_UNAVAILABLE", message: e?.message ?? String(e) };
92
+ }
93
+ const nowMs = now.getTime();
94
+ const skewMs = (o.policy?.max_clock_skew_seconds ?? 0) * 1000;
95
+ const certs = evidence.filter((e) => e.kind === "certificate" && Number.isFinite(Date.parse(e.as_of)));
96
+ if (!certs.length)
97
+ return { ok: false, status: 403, error: "KYA_CERTIFICATE_STATUS_UNKNOWN", message: "no status evidence for the principal's certificate" };
98
+ const freshest = certs.reduce((a, b) => (Date.parse(b.as_of) > Date.parse(a.as_of) ? b : a));
99
+ const from = freshest.source ? ` (${freshest.source})` : "";
100
+ if (Date.parse(freshest.as_of) - nowMs > skewMs)
101
+ return { ok: false, status: 403, error: "KYA_CERTIFICATE_STATUS_STALE", message: `certificate evidence${from} is dated ${freshest.as_of}, after the verifier's clock` };
102
+ if (!freshest.in_force)
103
+ return { ok: false, status: 403, error: "KYA_CERTIFICATE_REVOKED", message: `the principal's certificate is not in force${freshest.status ? ` (${freshest.status})` : ""} as of ${freshest.as_of}${from}` };
104
+ const until = freshest.valid_until ? Date.parse(freshest.valid_until) : Number.NaN;
105
+ if (Number.isFinite(until) && until < nowMs && o.policy?.past_valid_until !== "accept")
106
+ return { ok: false, status: 403, error: "KYA_CERTIFICATE_STATUS_STALE", message: `certificate evidence${from} expired at ${freshest.valid_until}` };
107
+ return { ok: true, evidence };
108
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kya-brasil/agent-tools",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "KYA Brasil for AI agents: verify that an agent acts for a Brazilian company (e-CNPJ-signed credential, revocation and company registration checked daily against the Receita's open data, no third-party service), check an action against its mandate, or give your own agent a credential (sign with the company's e-CNPJ: A1 .pfx or A3 token via PKCS#11, in code or with the kya-sign command). Sign in with KYA middleware (Hono, Express, Workers) and request signing. x402 payments (USDC on Base) handled for you. Adapters for Vercel AI SDK, LangChain, OpenAI and Anthropic.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -56,7 +56,7 @@
56
56
  "@x402/fetch": "^2.28.0",
57
57
  "pkijs": "^3.4.1",
58
58
  "viem": "^2.57.5",
59
- "@kya-brasil/core": "^0.10.0"
59
+ "@kya-brasil/core": "^0.18.0"
60
60
  },
61
61
  "devDependencies": {
62
62
  "@peculiar/asn1-schema": "^2.3.15",