@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 +41 -0
- package/dist/auth.d.ts +34 -2
- package/dist/auth.js +116 -18
- package/dist/offline.d.ts +87 -0
- package/dist/offline.js +108 -0
- package/package.json +2 -2
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
395
|
+
signer,
|
|
306
396
|
body: bodyState,
|
|
307
397
|
delegation,
|
|
308
|
-
|
|
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
|
|
313
|
-
if (!
|
|
314
|
-
return { credential_id: credentialId, status: v.status, allowed: false, reason: `outside the mandate of delegation ${i}`, checks:
|
|
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
|
|
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>;
|
package/dist/offline.js
ADDED
|
@@ -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.
|
|
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.
|
|
59
|
+
"@kya-brasil/core": "^0.18.0"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
62
|
"@peculiar/asn1-schema": "^2.3.15",
|