@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 +58 -0
- package/dist/auth.d.ts +84 -5
- package/dist/auth.js +209 -30
- package/dist/index.d.ts +6 -0
- package/dist/index.js +2 -0
- package/dist/offline.d.ts +87 -0
- package/dist/offline.js +108 -0
- package/package.json +2 -2
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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
212
|
-
|
|
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
|
|
395
|
+
signer,
|
|
229
396
|
body: bodyState,
|
|
230
|
-
|
|
231
|
-
|
|
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
|
|
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>;
|
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",
|