@kya-brasil/agent-tools 0.11.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
@@ -117,6 +117,47 @@ const headers = await signRequest({ credentialId, key: subKey, delegation: chain
117
117
  Short-lived (at most `max_ttl_seconds`, never past the credential), contained in the parent mandate, at most 3
118
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
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
+
120
161
  ## Person: an agent that acts for you (e-CPF)
121
162
 
122
163
  ```ts
package/dist/auth.d.ts CHANGED
@@ -1,5 +1,11 @@
1
- import { type Delegation, type DelegationAnchor } from "@kya-brasil/core";
1
+ import { type Delegation, type DelegationAnchor, type FreshnessVerdict, type PrincipalProof, type StatusEvidence } from "@kya-brasil/core";
2
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
+ */
3
9
  export declare const KYA_HEADERS: {
4
10
  readonly credential: "kya-credential";
5
11
  readonly created: "kya-created";
@@ -7,6 +13,8 @@ export declare const KYA_HEADERS: {
7
13
  readonly signature: "kya-signature";
8
14
  readonly content: "kya-content-sha256";
9
15
  readonly delegation: "kya-delegation";
16
+ readonly credentialDocument: "kya-credential-document";
17
+ readonly principalProof: "kya-principal-proof";
10
18
  };
11
19
  export type { Delegation } from "@kya-brasil/core";
12
20
  export declare function sha256Hex(data: string | ArrayBuffer | Uint8Array): Promise<string>;
@@ -30,7 +38,8 @@ export type SigningKey = {
30
38
  };
31
39
  /**
32
40
  * Signs a request with the agent key and returns the KYA headers to send with it. A sub-agent passes its
33
- * `delegation` chain (from signDelegation) and signs with its own key.
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.
34
43
  */
35
44
  export declare function signRequest(p: {
36
45
  credentialId: string;
@@ -41,6 +50,10 @@ export declare function signRequest(p: {
41
50
  ttlSeconds?: number;
42
51
  now?: Date;
43
52
  delegation?: Delegation[];
53
+ present?: {
54
+ credential: Credential | object;
55
+ proof: PrincipalProof;
56
+ };
44
57
  }): Promise<Record<string, string>>;
45
58
  /** The key a signing key verifies as, in the form a credential or a delegation names it. */
46
59
  export declare function agentKeyOf(key: SigningKey): Promise<AgentKey>;
@@ -71,6 +84,12 @@ export interface KyaAuthOptions extends KyaOptions {
71
84
  maxTtlSeconds?: number;
72
85
  /** Verify the body hash when the request has a body. Default true. */
73
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;
74
93
  }
75
94
  export interface KyaContext {
76
95
  credential_id: string;
@@ -94,6 +113,17 @@ export interface KyaContext {
94
113
  } | null;
95
114
  /** Whether the body hash was checked ("verified"), absent from the signature ("none") or not checkable ("skipped"). */
96
115
  body: "verified" | "none" | "skipped";
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
+ };
97
127
  /** Local evaluation of the mandate (no daily ceilings; those need the issuer). On a delegated request, of every mandate in the chain. */
98
128
  check(q: ActionQuery): LocalVerdict;
99
129
  /**
@@ -120,6 +150,8 @@ export interface LocalVerdict {
120
150
  detail: string;
121
151
  }[];
122
152
  not_evaluated: string[];
153
+ /** Offline mode with a freshness policy: the status freshness verdict (SPEC section 18, checkFreshAction). */
154
+ freshness?: FreshnessVerdict;
123
155
  }
124
156
  /**
125
157
  * Local evaluation of the reserved mandate fields: actions, max_per_operation, currencies, counterparties
package/dist/auth.js CHANGED
@@ -31,11 +31,17 @@
31
31
  * content-sha256: <hex of the body | ->
32
32
  */
33
33
  import { verifyMessage } from "viem";
34
- import { encodeDelegationHeader, decodeDelegationHeader, prepareDelegation, verifyDelegationChain } from "@kya-brasil/core";
34
+ import { SELF_ISSUER, checkFreshAction, decodePrincipalProofHeader, encodeDelegationHeader, encodePrincipalProofHeader, decodeDelegationHeader, prepareDelegation, verifyDelegationChain } from "@kya-brasil/core";
35
35
  import { privateKeyToAccount } from "viem/accounts";
36
36
  import { createKyaClient } from "./index.js";
37
37
  import { fromBase64Url, toBase64Url } from "./base64url.js";
38
- export const KYA_HEADERS = { credential: "kya-credential", created: "kya-created", expires: "kya-expires", signature: "kya-signature", content: "kya-content-sha256", delegation: "kya-delegation" };
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" };
39
45
  const enc = new TextEncoder();
40
46
  const hex = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
41
47
  const b64u = toBase64Url;
@@ -72,7 +78,8 @@ async function signText(key, text) {
72
78
  }
73
79
  /**
74
80
  * Signs a request with the agent key and returns the KYA headers to send with it. A sub-agent passes its
75
- * `delegation` chain (from signDelegation) and signs with its own key.
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.
76
83
  */
77
84
  export async function signRequest(p) {
78
85
  const created = Math.floor((p.now ?? new Date()).getTime() / 1000);
@@ -87,6 +94,10 @@ export async function signRequest(p) {
87
94
  headers[KYA_HEADERS.content] = contentSha256;
88
95
  if (delegationHeader)
89
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
+ }
90
101
  return headers;
91
102
  }
92
103
  /** The key a signing key verifies as, in the form a credential or a delegation names it. */
@@ -189,6 +200,8 @@ const headerOf = (headers, name) => {
189
200
  return Array.isArray(v) ? v[0] ?? null : v ?? null;
190
201
  };
191
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);
192
205
  const client = createKyaClient(opts);
193
206
  const cacheTtl = opts.cacheTtlMs ?? 60_000;
194
207
  const maxSkew = opts.maxSkewSeconds ?? 300;
@@ -220,6 +233,11 @@ export function createKyaAuth(opts = {}) {
220
233
  }
221
234
  }
222
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
+ }
223
241
  async function authenticateParts(p) {
224
242
  const credentialId = headerOf(p.headers, KYA_HEADERS.credential);
225
243
  const created = Number(headerOf(p.headers, KYA_HEADERS.created));
@@ -227,9 +245,14 @@ export function createKyaAuth(opts = {}) {
227
245
  const signature = headerOf(p.headers, KYA_HEADERS.signature);
228
246
  const contentHeader = headerOf(p.headers, KYA_HEADERS.content);
229
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);
230
250
  if (!credentialId || !signature || !Number.isFinite(created) || !Number.isFinite(expires))
231
251
  return fail(401, "KYA_HEADERS_MISSING", `send ${KYA_HEADERS.credential}, ${KYA_HEADERS.created}, ${KYA_HEADERS.expires} and ${KYA_HEADERS.signature}`);
232
- 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);
233
256
  if (created > now + maxSkew)
234
257
  return fail(401, "KYA_SIGNATURE_NOT_YET_VALID", "created is in the future");
235
258
  if (expires < now)
@@ -250,6 +273,10 @@ export function createKyaAuth(opts = {}) {
250
273
  }
251
274
  else if (contentHeader)
252
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 });
253
280
  let v;
254
281
  try {
255
282
  v = await verification(credentialId);
@@ -260,20 +287,19 @@ export function createKyaAuth(opts = {}) {
260
287
  }
261
288
  let delegation = null;
262
289
  if (delegationHeader) {
263
- const d = await delegationOf(credentialId, v, 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);
264
293
  if (!d.ok)
265
294
  return d.result;
266
295
  delegation = d.delegation;
267
296
  }
268
- const delegationSha256 = delegationHeader ? await sha256Hex(delegationHeader) : null;
269
- const text = requestText({ credentialId, method: p.method, url: p.url, created, expires, contentSha256: contentHeader, delegationSha256 });
270
297
  return finish(credentialId, v, text, signature, bodyState, delegation);
271
298
  }
299
+ const anchorOf = (credentialId, agent, credential) => ({ credential_id: credentialId, agent: agent, mandate: credential.mandate, expires_at: credential.expires_at });
272
300
  /** D-30: verifies a KYA-Delegation chain against the credential (offline, core rules). */
273
- async function delegationOf(credentialId, v, header) {
301
+ async function delegationOf(anchor, header, now) {
274
302
  const no = (status, error, message) => ({ ok: false, result: fail(status, error, message) });
275
- if (!v.credential)
276
- return no(403, "KYA_CREDENTIAL_NOT_ACTIVE", `credential is ${v.status}`);
277
303
  let chain;
278
304
  try {
279
305
  chain = decodeDelegationHeader(header);
@@ -281,20 +307,84 @@ export function createKyaAuth(opts = {}) {
281
307
  catch (e) {
282
308
  return no(401, "KYA_DELEGATION_MALFORMED", e.message);
283
309
  }
284
- const anchor = { credential_id: credentialId, agent: v.agent, mandate: v.credential.mandate, expires_at: v.credential.expires_at };
285
- const r = await verifyDelegationChain({ credential: anchor, chain });
310
+ const r = await verifyDelegationChain({ credential: anchor, chain, now });
286
311
  if (!r.ok)
287
312
  return no(r.error === "KYA_DELEGATION_NOT_ALLOWED" || r.error === "KYA_DELEGATION_BROADER" ? 403 : 401, r.error, r.message);
288
313
  return { ok: true, delegation: { depth: r.depth, delegate: r.delegate, expires_at: r.expires_at, chain: chain, mandates: r.mandates } };
289
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) };
363
+ }
290
364
  async function finish(credentialId, v, text, signature, bodyState, delegation = null) {
291
365
  const sig = await verifySignature(delegation ? delegation.delegate : v.agent, text, signature);
292
366
  if (!sig.ok)
293
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");
294
368
  if (v.status !== "active")
295
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) {
296
373
  const mandate = v.credential?.mandate ?? {};
297
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
+ };
298
388
  const ctx = {
299
389
  credential_id: credentialId,
300
390
  agent: v.agent,
@@ -302,26 +392,34 @@ export function createKyaAuth(opts = {}) {
302
392
  status: v.status,
303
393
  mandate,
304
394
  credential: v.credential,
305
- signer: sig.signer,
395
+ signer,
306
396
  body: bodyState,
307
397
  delegation,
308
- check: (q) => (delegation ? checkMandates(mandates, q) : checkMandate(mandate, q)),
398
+ ...(local ? { offline: local } : {}),
399
+ check: (q) => (local ? offlineCheck(q) : delegation ? checkMandates(mandates, q) : checkMandate(mandate, q)),
309
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
+ }
310
406
  if (delegation) {
311
407
  for (let i = 1; i < mandates.length; i++) {
312
- const local = checkMandate(mandates[i], q);
313
- if (!local.allowed) {
314
- return { credential_id: credentialId, status: v.status, allowed: false, reason: `outside the mandate of delegation ${i}`, checks: local.checks.map((c) => ({ field: c.rule, ok: c.ok, detail: c.detail })), not_evaluated: local.not_evaluated, delegation_level: 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 };
315
411
  }
316
412
  }
317
413
  }
318
414
  return client.allowed(credentialId, q);
319
415
  }
320
416
  };
321
- return { ok: true, kya: ctx };
417
+ return ctx;
322
418
  }
323
419
  /** Low level: verify a signature by a credential's agent key over a text you built yourself (used for signed responses). */
324
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");
325
423
  let v;
326
424
  try {
327
425
  v = await verification(p.credentialId);
@@ -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.11.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.11.0"
59
+ "@kya-brasil/core": "^0.18.0"
60
60
  },
61
61
  "devDependencies": {
62
62
  "@peculiar/asn1-schema": "^2.3.15",