@didis/client 0.1.0 → 0.2.1
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/CHANGELOG.md +46 -0
- package/README.md +62 -1
- package/dist/guard.d.ts +112 -0
- package/dist/guard.js +218 -0
- package/dist/index.d.ts +36 -3
- package/dist/index.js +12 -11
- package/dist/webcrypto.d.ts +1 -0
- package/dist/webcrypto.js +16 -0
- package/package.json +3 -2
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@didis/client` are listed here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the package uses [Semantic Versioning](https://semver.org/). Before 1.0.0, a minor version (0.x.0) may add features; breaking changes are called out under **Changed** with a migration note.
|
|
4
|
+
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
## [0.2.1] - 2026-10-14
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Guard option `capability` (on `check`, `wrap` and `withDidisGuard`): the DID.is capability a tool needs when it differs from the tool name. MCP and OpenAI tool names cannot contain `:`, so use `{ tool: "weather_forecast", capability: "weather:forecast" }`. Pins stay keyed by the tool name; DID.is is asked about the capability; the ALLOW cache is keyed by capability. Without `capability`, behaviour is unchanged (the tool name is the capability).
|
|
12
|
+
- `onDecision` events and `DidisGuardError.detail` now include `capability`.
|
|
13
|
+
- Examples for the MCP TypeScript SDK (`mcp-client.mjs`), LangChain (`langchain-tool.mjs`) and the OpenAI Agents SDK (`openai-agents-tool.mjs`).
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- A capability containing `*`, empty segments or whitespace is refused with `GUARD_MISCONFIGURED` before any network call. Wildcards belong in delegations, not in tool calls.
|
|
18
|
+
|
|
19
|
+
## [0.2.0] - 2026-10-13
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- Fail-closed tool guard: `createDidisGuard`, `withDidisGuard` and `DidisGuardError`. A guarded handler runs only when the tool definition still matches its pin and DID.is `verifyTool` answers `ALLOW` for the configured agent (and root, if set). Drift, `DENY`, network errors, `429` and unexpected answers all throw, and the handler does not run. See [Guard agent tools](https://docs.did.is/build/guard-tools/).
|
|
24
|
+
- Tool definition pinning: `canonicalJson` (RFC 8785 JCS), `toolDefinitionSha256`, `mcpInventoryHash` and `pinToolDefinitions`. Hashes are identical to the `definitionSha256` and `inventoryHash` values DID.is reports.
|
|
25
|
+
- `attestMcpPins(client, endpoint, pins)` compares a public MCP server, as observed by DID.is, with your pins.
|
|
26
|
+
- `verifyCredential` options `expectedNonce` and `trustedIssuerCertificates` (SD-JWT VC key binding and caller-chosen X.509 anchors), with the `CredentialVerifyOptions` type.
|
|
27
|
+
- Response types `IssuerTrust` and `SdJwtEvidence`, and the optional `issuerTrust` and `sdJwt` fields on `CredentialVerification`.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- Node 18: WebCrypto is loaded from `node:crypto` when there is no global `crypto`. Before this, `verifyWebhookSignature` and `verifyCustomerMonitorSignature` failed on Node 18 with `crypto is not defined`.
|
|
32
|
+
|
|
33
|
+
## [0.1.0] - 2026-10-05
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- First public release as `@didis/client`. Zero dependencies, ESM, Node 18+, Deno, Bun, browsers and edge runtimes.
|
|
38
|
+
- `DidisClient` for the DID.is public API (default base URL `https://did.is/api`): `health`, `resolve`, `resolveW3c`, `dereference`, `stream` (server-sent events), `history`, `diff`, `graph`, `verifyCredential`, `evaluatePolicy`, `inspectMcp`, `inspectA2a`, `verifyDelegation`, `authorize`, `verifyTool`, `fastVerifyDid` and `fastVerifyJws`.
|
|
39
|
+
- Self-hosted monitoring methods that need an admin token: `watch`, `watches`, `unwatch` and `events`.
|
|
40
|
+
- `DidisError` carrying the RFC 9457 problem body and `Retry-After`.
|
|
41
|
+
- Webhook helpers `verifyWebhookSignature` and `verifyCustomerMonitorSignature`.
|
|
42
|
+
|
|
43
|
+
[Unreleased]: https://github.com/did-is/didis-js/compare/v0.2.1...HEAD
|
|
44
|
+
[0.2.1]: https://github.com/did-is/didis-js/compare/v0.2.0...v0.2.1
|
|
45
|
+
[0.2.0]: https://github.com/did-is/didis-js/compare/v0.1.0...v0.2.0
|
|
46
|
+
[0.1.0]: https://github.com/did-is/didis-js/releases/tag/v0.1.0
|
package/README.md
CHANGED
|
@@ -33,6 +33,13 @@ for await (const ev of didis.stream("did:web:identity.foundation")) {
|
|
|
33
33
|
|
|
34
34
|
// Credentials: VC DM 2.0 / 1.1 with Data Integrity (eddsa-jcs-2022, ecdsa-jcs-2019) or VC-JOSE/VC-JWT.
|
|
35
35
|
// RDFC suites, ecdsa-sd-2023 and bbs-2023 are reported as UNSUPPORTED, never as valid.
|
|
36
|
+
const credential = {
|
|
37
|
+
"@context": ["https://www.w3.org/ns/credentials/v2"],
|
|
38
|
+
type: ["VerifiableCredential"],
|
|
39
|
+
issuer: "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK",
|
|
40
|
+
validFrom: "2026-01-01T00:00:00Z",
|
|
41
|
+
credentialSubject: { id: "did:key:z6MkiTBz1ymuepAQ4HEHYSF1H8quG5GLdmQR63zLFKfvUwi3" },
|
|
42
|
+
};
|
|
36
43
|
const vc = await didis.verifyCredential(credential);
|
|
37
44
|
console.log(vc.status, vc.headline);
|
|
38
45
|
|
|
@@ -44,6 +51,7 @@ const { evaluation } = await didis.evaluatePolicy("did:web:example.com", {
|
|
|
44
51
|
// Agents.
|
|
45
52
|
const mcp = await didis.inspectMcp("https://mcp.example.com/mcp");
|
|
46
53
|
const card = await didis.inspectA2a("https://agent.example.com");
|
|
54
|
+
const chain: string[] = []; // delegation receipt JWS strings, root first
|
|
47
55
|
const { authorization } = await didis.authorize(chain, "payments.refund", { trustedRoots: ["did:web:example.com"] });
|
|
48
56
|
|
|
49
57
|
try {
|
|
@@ -55,9 +63,62 @@ try {
|
|
|
55
63
|
|
|
56
64
|
Identifiers are percent-encoded exactly once by the client. Non-2xx responses throw `DidisError` with the RFC 9457 problem body (including the resolution `trace` where available).
|
|
57
65
|
|
|
66
|
+
## Guard agent tools
|
|
67
|
+
|
|
68
|
+
Wrap a tool handler so it runs only when (1) the tool definition the agent received still matches the one you approved and (2) DID.is says the agent's registered delegation grants the tool. Every failure throws `DidisGuardError` and the handler does not run (fail-closed).
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
import { DidisClient, createDidisGuard, pinToolDefinitions } from "@didis/client";
|
|
72
|
+
|
|
73
|
+
const didis = new DidisClient();
|
|
74
|
+
// Once, after reviewing the server's tools/list: store these pins in your config.
|
|
75
|
+
const pins = await pinToolDefinitions(reviewedTools);
|
|
76
|
+
|
|
77
|
+
const guard = createDidisGuard({
|
|
78
|
+
client: didis,
|
|
79
|
+
agent: process.env.AGENT_DID!, // from configuration, never from model output
|
|
80
|
+
root: process.env.ROOT_DID, // require this delegation root
|
|
81
|
+
pins,
|
|
82
|
+
requirePins: true,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const createIssue = guard.wrap(
|
|
86
|
+
{
|
|
87
|
+
tool: "github_create_issue", // the name in tools/list (pin key)
|
|
88
|
+
capability: "github:create_issue", // what the delegation must grant
|
|
89
|
+
definition: () => currentTools.get("github_create_issue"),
|
|
90
|
+
},
|
|
91
|
+
async (args) => github.createIssue(args),
|
|
92
|
+
);
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
**Tool name vs capability.** MCP and OpenAI tool names may only contain letters, digits, `_`, `-` and `.`, so a tool cannot be called `github:create_issue`. DID.is capabilities use `:` namespaces so that `github:*` can grant a whole group. Pass both: `tool` is used for the pin and error messages, `capability` is what DID.is is asked about. Without `capability`, the tool name is used. A capability must be exact (no `*`, no empty segments, no spaces); an invalid one throws `GUARD_MISCONFIGURED` before any network call. Since 0.2.1.
|
|
96
|
+
|
|
97
|
+
Guarding MCP clients, LangChain and the OpenAI Agents SDK: see [`examples/`](examples/) and [Guard agent tools](https://docs.did.is/build/guard-tools/#use-with-mcp-and-agent-frameworks).
|
|
98
|
+
|
|
99
|
+
- The definition check is local (RFC 8785 JCS + SHA-256, the same `definitionSha256` DID.is reports) and makes no network call.
|
|
100
|
+
- The delegation check calls `verifyTool`. An ALLOW is reused for 30 s by default (`allowCacheMs`), never past the delegation expiry; DENY and errors are never cached.
|
|
101
|
+
- Error codes: `TOOL_DRIFT`, `TOOL_DEFINITION_MISSING`, `TOOL_NOT_AUTHORIZED`, `GUARD_UNAVAILABLE`, `GUARD_MISCONFIGURED`.
|
|
102
|
+
- One tool only: `withDidisGuard(handler, { client, agent, root, tool, capability, pin, definition })`.
|
|
103
|
+
- `onDecision` and `DidisGuardError.detail` include both `tool` and `capability`.
|
|
104
|
+
- `attestMcpPins(client, endpoint, pins)` asks DID.is to inspect a public MCP server and compares its hashes with your pins. Run it at startup or deploy time, not per call: it is an outbound, rate-limited request, and servers that need login or run on a private network cannot be inspected.
|
|
105
|
+
|
|
58
106
|
## Webhooks
|
|
59
107
|
|
|
60
|
-
|
|
108
|
+
Console project monitor events use `verifyCustomerMonitorSignature`:
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
import { verifyCustomerMonitorSignature } from "@didis/client";
|
|
112
|
+
|
|
113
|
+
const ok = await verifyCustomerMonitorSignature(
|
|
114
|
+
secret,
|
|
115
|
+
rawBody,
|
|
116
|
+
req.headers["x-didis-signature"],
|
|
117
|
+
{ tenantId, projectId, eventId: req.headers["x-didis-event-id"] },
|
|
118
|
+
);
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
For self-hosted admin daemon monitoring deliveries, use `verifyWebhookSignature`:
|
|
61
122
|
|
|
62
123
|
```ts
|
|
63
124
|
import { verifyWebhookSignature } from "@didis/client";
|
package/dist/guard.d.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fail-closed pre-execution guard for agent tools.
|
|
3
|
+
*
|
|
4
|
+
* Before a wrapped tool handler runs, the guard checks:
|
|
5
|
+
* 1. Definition pin (local, no network): the tool definition the agent actually received
|
|
6
|
+
* hashes to the SHA-256 you approved (same RFC 8785 JCS hash DID.is reports as
|
|
7
|
+
* `definitionSha256`). Catches MCP "rug-pull" drift, including servers DID.is cannot reach.
|
|
8
|
+
* 2. Delegation (DID.is `verify-tool`): the configured agent DID holds a registered, VALID
|
|
9
|
+
* delegation whose leaf capabilities grant the tool's capability. ALLOW is cached briefly;
|
|
10
|
+
* DENY and errors are never cached.
|
|
11
|
+
*
|
|
12
|
+
* Tool name vs capability: MCP tool names may only use `A-Z a-z 0-9 _ - .` (SEP-986), while
|
|
13
|
+
* DID.is capabilities are namespaced with `:` (e.g. `weather:forecast`). Pass both when they
|
|
14
|
+
* differ: `{ tool: "weather_forecast", capability: "weather:forecast" }`. Pins are keyed by the
|
|
15
|
+
* tool name (what `tools/list` returns); DID.is is asked about the capability. When
|
|
16
|
+
* `capability` is omitted, the tool name is used as the capability.
|
|
17
|
+
*
|
|
18
|
+
* Any failure (drift, DENY, network error, 429, malformed answer) throws `DidisGuardError`
|
|
19
|
+
* and the handler is not called. `attestMcpPins` is an optional, separate startup check that
|
|
20
|
+
* asks DID.is to fetch a public MCP server and compares its hashes with your pins; do not call
|
|
21
|
+
* it per tool call (it makes an outbound request and is rate limited).
|
|
22
|
+
*/
|
|
23
|
+
import type { DidisClient, McpInspection } from "./index.js";
|
|
24
|
+
export type DidisGuardErrorCode = "TOOL_DRIFT" | "TOOL_DEFINITION_MISSING" | "TOOL_NOT_AUTHORIZED" | "GUARD_UNAVAILABLE" | "GUARD_MISCONFIGURED";
|
|
25
|
+
export declare class DidisGuardError extends Error {
|
|
26
|
+
readonly code: DidisGuardErrorCode;
|
|
27
|
+
readonly tool: string;
|
|
28
|
+
readonly detail?: Record<string, unknown>;
|
|
29
|
+
constructor(code: DidisGuardErrorCode, tool: string, message: string, detail?: Record<string, unknown>);
|
|
30
|
+
}
|
|
31
|
+
/** RFC 8785 (JCS) canonical JSON, matching the DID.is server's `rfc8785-binary64-v1` profile. */
|
|
32
|
+
export declare function canonicalJson(value: unknown): string;
|
|
33
|
+
/** SHA-256 of a tool definition exactly as an MCP server returned it in `tools/list`. */
|
|
34
|
+
export declare function toolDefinitionSha256(definition: unknown): Promise<string>;
|
|
35
|
+
/** Inventory hash of a `tools/list` result, identical to DID.is `inventoryHash`. */
|
|
36
|
+
export declare function mcpInventoryHash(tools: {
|
|
37
|
+
name?: unknown;
|
|
38
|
+
}[]): Promise<string>;
|
|
39
|
+
/** Builds `{ toolName: definitionSha256 }` pins from a `tools/list` result you have reviewed. */
|
|
40
|
+
export declare function pinToolDefinitions(tools: {
|
|
41
|
+
name?: unknown;
|
|
42
|
+
}[]): Promise<Record<string, string>>;
|
|
43
|
+
type Definition = unknown | (() => unknown | Promise<unknown>);
|
|
44
|
+
export interface DidisGuardOptions {
|
|
45
|
+
client: DidisClient;
|
|
46
|
+
/** The agent DID from your runtime configuration. Never take it from model output. */
|
|
47
|
+
agent: string;
|
|
48
|
+
/** Require the delegation chain to start at this root DID. Strongly recommended. */
|
|
49
|
+
root?: string;
|
|
50
|
+
/** Approved `{ toolName: definitionSha256 }`. A pinned tool must be given its definition. */
|
|
51
|
+
pins?: Record<string, string>;
|
|
52
|
+
/** Refuse tools that have no pin. Default `false`. */
|
|
53
|
+
requirePins?: boolean;
|
|
54
|
+
/** How long an ALLOW is reused (ms). Default 30 s; 0 disables. Capped by the delegation expiry. */
|
|
55
|
+
allowCacheMs?: number;
|
|
56
|
+
/** Called after every decision (for audit logs). Must not throw. */
|
|
57
|
+
onDecision?: (event: {
|
|
58
|
+
tool: string;
|
|
59
|
+
capability: string;
|
|
60
|
+
allowed: boolean;
|
|
61
|
+
code?: DidisGuardErrorCode;
|
|
62
|
+
reason: string;
|
|
63
|
+
cached: boolean;
|
|
64
|
+
}) => void;
|
|
65
|
+
}
|
|
66
|
+
export interface GuardCallOptions {
|
|
67
|
+
/** The tool name the agent sees (MCP `tools/list` name, pin key), e.g. `github_create_issue`. */
|
|
68
|
+
tool: string;
|
|
69
|
+
/**
|
|
70
|
+
* The DID.is capability this tool needs, e.g. `github:create_issue`. Defaults to `tool`.
|
|
71
|
+
* Must be an exact capability (no `*`): wildcards belong in delegations, not in tool calls.
|
|
72
|
+
*/
|
|
73
|
+
capability?: string;
|
|
74
|
+
/** The definition the agent received, or a function returning the current one. */
|
|
75
|
+
definition?: Definition;
|
|
76
|
+
}
|
|
77
|
+
export interface DidisGuard {
|
|
78
|
+
/** Throws `DidisGuardError` unless the call may proceed. */
|
|
79
|
+
check(call: GuardCallOptions): Promise<void>;
|
|
80
|
+
/** Wraps a handler so it only runs after `check` passes. */
|
|
81
|
+
wrap<A extends unknown[], R>(call: GuardCallOptions, handler: (...args: A) => R | Promise<R>): (...args: A) => Promise<Awaited<R>>;
|
|
82
|
+
/** Drops cached ALLOW decisions (e.g. after revoking a delegation). */
|
|
83
|
+
clearCache(): void;
|
|
84
|
+
}
|
|
85
|
+
export declare function createDidisGuard(options: DidisGuardOptions): DidisGuard;
|
|
86
|
+
/**
|
|
87
|
+
* One-line form: `withDidisGuard(handler, { client, agent, root, tool, capability, pin, definition })`.
|
|
88
|
+
* For several tools, create one guard with `createDidisGuard` so the ALLOW cache is shared.
|
|
89
|
+
*/
|
|
90
|
+
export declare function withDidisGuard<A extends unknown[], R>(handler: (...args: A) => R | Promise<R>, options: Omit<DidisGuardOptions, "pins"> & GuardCallOptions & {
|
|
91
|
+
pin?: string;
|
|
92
|
+
}): (...args: A) => Promise<Awaited<R>>;
|
|
93
|
+
export interface McpAttestation {
|
|
94
|
+
ok: boolean;
|
|
95
|
+
inventoryHash: string;
|
|
96
|
+
/** Pinned tools whose DID.is-observed hash differs, or that DID.is did not see. */
|
|
97
|
+
mismatched: {
|
|
98
|
+
tool: string;
|
|
99
|
+
expected: string;
|
|
100
|
+
observed?: string;
|
|
101
|
+
}[];
|
|
102
|
+
/** Tools DID.is saw that you have not pinned. */
|
|
103
|
+
unpinned: string[];
|
|
104
|
+
status: McpInspection["status"];
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Optional startup check: asks DID.is to inspect a public MCP endpoint and compares the observed
|
|
108
|
+
* `definitionSha256` values with your pins. Use at deploy/startup, never per tool call.
|
|
109
|
+
* `AUTH_REQUIRED` or any non-INSPECTED status returns `ok: false`.
|
|
110
|
+
*/
|
|
111
|
+
export declare function attestMcpPins(client: DidisClient, endpoint: string, pins: Record<string, string>): Promise<McpAttestation>;
|
|
112
|
+
export {};
|
package/dist/guard.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { subtle } from "./webcrypto.js";
|
|
2
|
+
export class DidisGuardError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
tool;
|
|
5
|
+
detail;
|
|
6
|
+
constructor(code, tool, message, detail) {
|
|
7
|
+
super(`${code}: ${message}`);
|
|
8
|
+
this.name = "DidisGuardError";
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.tool = tool;
|
|
11
|
+
this.detail = detail;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** RFC 8785 (JCS) canonical JSON, matching the DID.is server's `rfc8785-binary64-v1` profile. */
|
|
15
|
+
export function canonicalJson(value) {
|
|
16
|
+
if (value === null)
|
|
17
|
+
return "null";
|
|
18
|
+
switch (typeof value) {
|
|
19
|
+
case "boolean":
|
|
20
|
+
return value ? "true" : "false";
|
|
21
|
+
case "number":
|
|
22
|
+
if (!Number.isFinite(value))
|
|
23
|
+
throw new TypeError("JCS: non-finite numbers are not JSON");
|
|
24
|
+
return Object.is(value, -0) ? "0" : String(value);
|
|
25
|
+
case "string":
|
|
26
|
+
return JSON.stringify(value);
|
|
27
|
+
case "object": {
|
|
28
|
+
if (Array.isArray(value))
|
|
29
|
+
return `[${value.map((v) => canonicalJson(v === undefined ? null : v)).join(",")}]`;
|
|
30
|
+
const obj = value;
|
|
31
|
+
const keys = Object.keys(obj).filter((k) => obj[k] !== undefined).sort(); // UTF-16 code unit order
|
|
32
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`).join(",")}}`;
|
|
33
|
+
}
|
|
34
|
+
default:
|
|
35
|
+
throw new TypeError(`JCS: unsupported value of type ${typeof value}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async function sha256Hex(text) {
|
|
39
|
+
const digest = new Uint8Array(await (await subtle()).digest("SHA-256", new TextEncoder().encode(text)));
|
|
40
|
+
return Array.from(digest, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
41
|
+
}
|
|
42
|
+
/** SHA-256 of a tool definition exactly as an MCP server returned it in `tools/list`. */
|
|
43
|
+
export function toolDefinitionSha256(definition) {
|
|
44
|
+
return sha256Hex(canonicalJson(definition));
|
|
45
|
+
}
|
|
46
|
+
function byteCompare(a, b) {
|
|
47
|
+
const x = new TextEncoder().encode(a);
|
|
48
|
+
const y = new TextEncoder().encode(b);
|
|
49
|
+
for (let i = 0; i < Math.min(x.length, y.length); i++)
|
|
50
|
+
if (x[i] !== y[i])
|
|
51
|
+
return x[i] - y[i];
|
|
52
|
+
return x.length - y.length;
|
|
53
|
+
}
|
|
54
|
+
/** Inventory hash of a `tools/list` result, identical to DID.is `inventoryHash`. */
|
|
55
|
+
export async function mcpInventoryHash(tools) {
|
|
56
|
+
const pairs = await Promise.all(tools.map(async (t) => [typeof t.name === "string" ? t.name : "", await toolDefinitionSha256(t)]));
|
|
57
|
+
pairs.sort((a, b) => byteCompare(a[0], b[0]));
|
|
58
|
+
return sha256Hex(canonicalJson(pairs));
|
|
59
|
+
}
|
|
60
|
+
/** Builds `{ toolName: definitionSha256 }` pins from a `tools/list` result you have reviewed. */
|
|
61
|
+
export async function pinToolDefinitions(tools) {
|
|
62
|
+
const pins = {};
|
|
63
|
+
for (const t of tools) {
|
|
64
|
+
if (typeof t.name !== "string" || !t.name)
|
|
65
|
+
throw new TypeError("every tool needs a string name");
|
|
66
|
+
if (t.name in pins)
|
|
67
|
+
throw new TypeError(`duplicate tool name '${t.name}'`);
|
|
68
|
+
pins[t.name] = await toolDefinitionSha256(t);
|
|
69
|
+
}
|
|
70
|
+
return pins;
|
|
71
|
+
}
|
|
72
|
+
const DID = /^did:[a-z0-9]+:\S+$/;
|
|
73
|
+
/** An exact DID.is capability: `:`-separated non-empty segments, no whitespace, no `*`. */
|
|
74
|
+
const CAPABILITY = /^[^\s:*]+(?::[^\s:*]+)*$/;
|
|
75
|
+
export function createDidisGuard(options) {
|
|
76
|
+
const { client, agent, root, pins = {}, requirePins = false, onDecision } = options;
|
|
77
|
+
const allowCacheMs = options.allowCacheMs ?? 30_000;
|
|
78
|
+
if (!client || typeof client.verifyTool !== "function")
|
|
79
|
+
throw new DidisGuardError("GUARD_MISCONFIGURED", "", "a DidisClient is required");
|
|
80
|
+
if (typeof agent !== "string" || !DID.test(agent))
|
|
81
|
+
throw new DidisGuardError("GUARD_MISCONFIGURED", "", "agent must be a DID");
|
|
82
|
+
if (root !== undefined && (typeof root !== "string" || !DID.test(root)))
|
|
83
|
+
throw new DidisGuardError("GUARD_MISCONFIGURED", "", "root must be a DID");
|
|
84
|
+
if (!Number.isFinite(allowCacheMs) || allowCacheMs < 0)
|
|
85
|
+
throw new DidisGuardError("GUARD_MISCONFIGURED", "", "allowCacheMs must be >= 0");
|
|
86
|
+
for (const [name, pin] of Object.entries(pins)) {
|
|
87
|
+
if (!/^[0-9a-f]{64}$/.test(pin))
|
|
88
|
+
throw new DidisGuardError("GUARD_MISCONFIGURED", name, "pins must be lowercase hex SHA-256");
|
|
89
|
+
}
|
|
90
|
+
const pinFor = (tool) => (Object.prototype.hasOwnProperty.call(pins, tool) ? pins[tool] : undefined);
|
|
91
|
+
const allowed = new Map();
|
|
92
|
+
const report = (tool, capability, ok, reason, cached, code) => {
|
|
93
|
+
try {
|
|
94
|
+
onDecision?.({ tool, capability, allowed: ok, code, reason, cached });
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
/* audit hooks must not change the decision */
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
const fail = (code, tool, capability, reason, detail) => {
|
|
101
|
+
report(tool, capability, false, reason, false, code);
|
|
102
|
+
throw new DidisGuardError(code, tool, reason, { ...detail, capability });
|
|
103
|
+
};
|
|
104
|
+
async function checkDefinition(tool, capability, definition) {
|
|
105
|
+
const pin = pinFor(tool);
|
|
106
|
+
if (pin === undefined) {
|
|
107
|
+
if (requirePins)
|
|
108
|
+
fail("TOOL_DEFINITION_MISSING", tool, capability, `no pinned definition for '${tool}'`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
let def;
|
|
112
|
+
try {
|
|
113
|
+
def = typeof definition === "function" ? await definition() : definition;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return fail("TOOL_DEFINITION_MISSING", tool, capability, `could not read the current definition of '${tool}'`);
|
|
117
|
+
}
|
|
118
|
+
if (def === undefined || def === null)
|
|
119
|
+
fail("TOOL_DEFINITION_MISSING", tool, capability, `'${tool}' is pinned but no definition was supplied`);
|
|
120
|
+
let actual;
|
|
121
|
+
try {
|
|
122
|
+
actual = await toolDefinitionSha256(def);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return fail("TOOL_DRIFT", tool, capability, `the definition of '${tool}' is not valid JSON`);
|
|
126
|
+
}
|
|
127
|
+
if (def.name !== tool)
|
|
128
|
+
fail("TOOL_DRIFT", tool, capability, `definition name does not match '${tool}'`, { expected: pin, actual });
|
|
129
|
+
if (actual !== pin)
|
|
130
|
+
fail("TOOL_DRIFT", tool, capability, `definition of '${tool}' changed since it was approved`, { expected: pin, actual });
|
|
131
|
+
}
|
|
132
|
+
async function checkDelegation(tool, capability) {
|
|
133
|
+
const now = Date.now();
|
|
134
|
+
const until = allowed.get(capability);
|
|
135
|
+
if (until !== undefined && until > now) {
|
|
136
|
+
report(tool, capability, true, "cached ALLOW", true);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
allowed.delete(capability);
|
|
140
|
+
let auth;
|
|
141
|
+
try {
|
|
142
|
+
auth = await client.verifyTool(agent, capability, root);
|
|
143
|
+
}
|
|
144
|
+
catch (e) {
|
|
145
|
+
const status = e.status;
|
|
146
|
+
return fail("GUARD_UNAVAILABLE", tool, capability, `DID.is verify-tool failed${status ? ` (HTTP ${status})` : ""}`, { status });
|
|
147
|
+
}
|
|
148
|
+
const exact = auth && auth.decision === "ALLOW" && auth.authorized === true && auth.agent === agent && auth.tool === capability;
|
|
149
|
+
if (!exact) {
|
|
150
|
+
if (auth && (auth.decision === "DENY" || auth.authorized === false)) {
|
|
151
|
+
fail("TOOL_NOT_AUTHORIZED", tool, capability, auth.reason || "delegation does not grant this capability", { matchedCapability: auth.matchedCapability });
|
|
152
|
+
}
|
|
153
|
+
fail("GUARD_UNAVAILABLE", tool, capability, "DID.is returned an unexpected authorization answer");
|
|
154
|
+
}
|
|
155
|
+
if (root !== undefined && auth.root !== root)
|
|
156
|
+
fail("TOOL_NOT_AUTHORIZED", tool, capability, "delegation root does not match the configured root");
|
|
157
|
+
if (allowCacheMs > 0) {
|
|
158
|
+
const expires = auth.expires ? Date.parse(auth.expires) : NaN;
|
|
159
|
+
const until2 = Math.min(now + allowCacheMs, Number.isFinite(expires) ? expires : now + allowCacheMs);
|
|
160
|
+
if (until2 > now)
|
|
161
|
+
allowed.set(capability, until2);
|
|
162
|
+
}
|
|
163
|
+
report(tool, capability, true, auth.reason, false);
|
|
164
|
+
}
|
|
165
|
+
async function check({ tool, capability, definition }) {
|
|
166
|
+
if (typeof tool !== "string" || !tool.trim() || tool !== tool.trim())
|
|
167
|
+
fail("GUARD_MISCONFIGURED", String(tool), String(capability ?? tool), "tool must be a non-empty, trimmed name");
|
|
168
|
+
const cap = capability === undefined ? tool : capability;
|
|
169
|
+
if (typeof cap !== "string" || !CAPABILITY.test(cap)) {
|
|
170
|
+
fail("GUARD_MISCONFIGURED", tool, String(cap), "capability must be exact: ':'-separated non-empty segments, no spaces, no '*'");
|
|
171
|
+
}
|
|
172
|
+
await checkDefinition(tool, cap, definition);
|
|
173
|
+
await checkDelegation(tool, cap);
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
check,
|
|
177
|
+
wrap(call, handler) {
|
|
178
|
+
if (typeof handler !== "function")
|
|
179
|
+
throw new DidisGuardError("GUARD_MISCONFIGURED", call?.tool ?? "", "handler must be a function");
|
|
180
|
+
return async (...args) => {
|
|
181
|
+
await check(call);
|
|
182
|
+
return await handler(...args);
|
|
183
|
+
};
|
|
184
|
+
},
|
|
185
|
+
clearCache() {
|
|
186
|
+
allowed.clear();
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* One-line form: `withDidisGuard(handler, { client, agent, root, tool, capability, pin, definition })`.
|
|
192
|
+
* For several tools, create one guard with `createDidisGuard` so the ALLOW cache is shared.
|
|
193
|
+
*/
|
|
194
|
+
export function withDidisGuard(handler, options) {
|
|
195
|
+
const { tool, capability, definition, pin, ...rest } = options;
|
|
196
|
+
const guard = createDidisGuard({ ...rest, pins: pin ? { [tool]: pin } : undefined });
|
|
197
|
+
return guard.wrap({ tool, capability, definition }, handler);
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Optional startup check: asks DID.is to inspect a public MCP endpoint and compares the observed
|
|
201
|
+
* `definitionSha256` values with your pins. Use at deploy/startup, never per tool call.
|
|
202
|
+
* `AUTH_REQUIRED` or any non-INSPECTED status returns `ok: false`.
|
|
203
|
+
*/
|
|
204
|
+
export async function attestMcpPins(client, endpoint, pins) {
|
|
205
|
+
const inspection = await client.inspectMcp(endpoint);
|
|
206
|
+
const seen = new Map(inspection.tools.map((t) => [t.name, t.definitionSha256]));
|
|
207
|
+
const mismatched = Object.entries(pins)
|
|
208
|
+
.filter(([tool, expected]) => seen.get(tool) !== expected)
|
|
209
|
+
.map(([tool, expected]) => ({ tool, expected, observed: seen.get(tool) }));
|
|
210
|
+
const unpinned = [...seen.keys()].filter((t) => !Object.prototype.hasOwnProperty.call(pins, t));
|
|
211
|
+
return {
|
|
212
|
+
ok: inspection.status === "INSPECTED" && mismatched.length === 0,
|
|
213
|
+
inventoryHash: inspection.inventoryHash,
|
|
214
|
+
mismatched,
|
|
215
|
+
unpinned,
|
|
216
|
+
status: inspection.status,
|
|
217
|
+
};
|
|
218
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -226,6 +226,35 @@ export interface Check {
|
|
|
226
226
|
status: "PASS" | "FAIL" | "WARN" | "SKIP" | "UNSUPPORTED" | "INDETERMINATE" | string;
|
|
227
227
|
detail: string;
|
|
228
228
|
}
|
|
229
|
+
export interface IssuerTrust {
|
|
230
|
+
anchor: "DID" | "X509_CERTIFICATE" | "ISSUER_METADATA" | "NONE";
|
|
231
|
+
result: "NOT_APPLICABLE" | "NOT_ESTABLISHED" | "CALLER_ANCHORED" | "CHAIN_INVALID";
|
|
232
|
+
trustedListChecked: false;
|
|
233
|
+
chain?: {
|
|
234
|
+
sha256: string;
|
|
235
|
+
subjectCn?: string;
|
|
236
|
+
issuerCn?: string;
|
|
237
|
+
notAfter?: string;
|
|
238
|
+
}[];
|
|
239
|
+
metadataUrl?: string;
|
|
240
|
+
proves: string;
|
|
241
|
+
doesNotProve: string;
|
|
242
|
+
}
|
|
243
|
+
export interface SdJwtEvidence {
|
|
244
|
+
sdAlg: string;
|
|
245
|
+
disclosures: number;
|
|
246
|
+
decoyDigests: number;
|
|
247
|
+
registeredClaims: Record<string, unknown>;
|
|
248
|
+
keyBinding: "VERIFIED" | "ABSENT" | "INVALID" | "UNSUPPORTED" | "INDETERMINATE";
|
|
249
|
+
}
|
|
250
|
+
export interface CredentialVerifyOptions {
|
|
251
|
+
/** JWT `aud` / SD-JWT Key Binding `aud` constraint. */
|
|
252
|
+
expectedAudience?: string;
|
|
253
|
+
/** SD-JWT Key Binding `nonce` constraint; without it replay is not excluded. */
|
|
254
|
+
expectedNonce?: string;
|
|
255
|
+
/** Caller-chosen X.509 anchors (PEM or base64 DER) for SD-JWT VC `x5c`. Without them `x5c` is never VALID. */
|
|
256
|
+
trustedIssuerCertificates?: string[];
|
|
257
|
+
}
|
|
229
258
|
export interface CredentialVerification {
|
|
230
259
|
status: string;
|
|
231
260
|
headline: string;
|
|
@@ -271,6 +300,11 @@ export interface CredentialVerification {
|
|
|
271
300
|
domainBinding?: string;
|
|
272
301
|
detail: string;
|
|
273
302
|
};
|
|
303
|
+
/** SD-JWT VC only. Categorical, never a score; EU trusted lists are never checked. */
|
|
304
|
+
issuerTrust?: IssuerTrust;
|
|
305
|
+
/** SD-JWT VC only. No disclosed claim values are ever returned. */
|
|
306
|
+
sdJwt?: SdJwtEvidence;
|
|
307
|
+
/** For SD-JWT VC: disclosed claim paths and digests only, never values. */
|
|
274
308
|
claims: unknown;
|
|
275
309
|
credential: unknown;
|
|
276
310
|
limitations: string[];
|
|
@@ -629,9 +663,7 @@ export declare class DidisClient {
|
|
|
629
663
|
diff(did: string, from?: string, to?: string): Promise<SemanticDiff>;
|
|
630
664
|
graph(did: string): Promise<GraphResponse>;
|
|
631
665
|
/** Audience is checked only when an expected recipient is supplied. Pass raw JSON as a string to preserve member identity. */
|
|
632
|
-
verifyCredential(credential: unknown, options?:
|
|
633
|
-
expectedAudience?: string;
|
|
634
|
-
}): Promise<CredentialVerification>;
|
|
666
|
+
verifyCredential(credential: unknown, options?: CredentialVerifyOptions): Promise<CredentialVerification>;
|
|
635
667
|
evaluatePolicy(did: string, policy: {
|
|
636
668
|
name?: string;
|
|
637
669
|
rules: PolicyRules;
|
|
@@ -674,3 +706,4 @@ export declare function verifyCustomerMonitorSignature(secret: string, body: str
|
|
|
674
706
|
projectId: string;
|
|
675
707
|
eventId: string;
|
|
676
708
|
}, maxAgeSeconds?: number): Promise<boolean>;
|
|
709
|
+
export * from "./guard.js";
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* DID.is client SDK (TypeScript — Node 18+, Deno, Bun, browsers, edge runtimes).
|
|
3
|
-
*
|
|
4
|
-
* Every identifier is percent-encoded exactly once; the resolver decodes it exactly once.
|
|
5
|
-
* Errors are thrown as `DidisError`, carrying the RFC 9457 problem object returned by the API.
|
|
6
|
-
*/
|
|
1
|
+
import { subtle } from "./webcrypto.js";
|
|
7
2
|
// ---------------------------------------------------------------------------------------------
|
|
8
3
|
// Client
|
|
9
4
|
// ---------------------------------------------------------------------------------------------
|
|
@@ -137,7 +132,12 @@ export class DidisClient {
|
|
|
137
132
|
}
|
|
138
133
|
/** Audience is checked only when an expected recipient is supplied. Pass raw JSON as a string to preserve member identity. */
|
|
139
134
|
verifyCredential(credential, options = {}) {
|
|
140
|
-
return this.request("POST", "/v1/credentials/verify", {
|
|
135
|
+
return this.request("POST", "/v1/credentials/verify", {
|
|
136
|
+
credential,
|
|
137
|
+
expectedAudience: options.expectedAudience,
|
|
138
|
+
expectedNonce: options.expectedNonce,
|
|
139
|
+
trustedIssuerCertificates: options.trustedIssuerCertificates,
|
|
140
|
+
});
|
|
141
141
|
}
|
|
142
142
|
evaluatePolicy(did, policy, credential) {
|
|
143
143
|
return this.request("POST", "/v1/policies/evaluate", { did, policy, credential });
|
|
@@ -187,8 +187,8 @@ export async function verifyWebhookSignature(secret, timestamp, body, signatureH
|
|
|
187
187
|
if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > toleranceSeconds)
|
|
188
188
|
return false;
|
|
189
189
|
const te = new TextEncoder();
|
|
190
|
-
const key = await
|
|
191
|
-
const mac = new Uint8Array(await
|
|
190
|
+
const key = await (await subtle()).importKey("raw", te.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
191
|
+
const mac = new Uint8Array(await (await subtle()).sign("HMAC", key, te.encode(`${timestamp}.${body}`)));
|
|
192
192
|
const expected = `sha256=${Array.from(mac, (b) => b.toString(16).padStart(2, "0")).join("")}`;
|
|
193
193
|
if (expected.length !== signatureHeader.length)
|
|
194
194
|
return false;
|
|
@@ -209,9 +209,9 @@ export async function verifyCustomerMonitorSignature(secret, body, signature, ex
|
|
|
209
209
|
if (!match || !Number.isSafeInteger(timestamp) || !Number.isInteger(maxAgeSeconds) || maxAgeSeconds < 1 || maxAgeSeconds > 30 * 86400 || timestamp > now + 60 || now - timestamp >= maxAgeSeconds || bytes.length > 300 * 1024 || !secret)
|
|
210
210
|
return false;
|
|
211
211
|
try {
|
|
212
|
-
const key = await
|
|
212
|
+
const key = await (await subtle()).importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
|
|
213
213
|
const mac = Uint8Array.from(match[2].match(/../g), value => parseInt(value, 16));
|
|
214
|
-
if (!await
|
|
214
|
+
if (!await (await subtle()).verify("HMAC", key, mac, new TextEncoder().encode(`${match[1]}.${body}`)))
|
|
215
215
|
return false;
|
|
216
216
|
const event = JSON.parse(body);
|
|
217
217
|
return event.specVersion === "didis.customer-monitor.v1" && event.createdAt === timestamp
|
|
@@ -223,3 +223,4 @@ export async function verifyCustomerMonitorSignature(secret, body, signature, ex
|
|
|
223
223
|
return false;
|
|
224
224
|
}
|
|
225
225
|
}
|
|
226
|
+
export * from "./guard.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function subtle(): Promise<SubtleCrypto>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** WebCrypto: global in Node 19+, browsers, Deno, Bun and edge; Node 18 needs `node:crypto`. */
|
|
2
|
+
let cached;
|
|
3
|
+
export function subtle() {
|
|
4
|
+
if (!cached) {
|
|
5
|
+
const g = globalThis.crypto;
|
|
6
|
+
cached = g?.subtle
|
|
7
|
+
? Promise.resolve(g.subtle)
|
|
8
|
+
: import("node:crypto").then((m) => {
|
|
9
|
+
if (!m.webcrypto?.subtle)
|
|
10
|
+
throw new Error("WebCrypto is not available in this runtime");
|
|
11
|
+
return m.webcrypto.subtle;
|
|
12
|
+
});
|
|
13
|
+
cached.catch(() => { cached = undefined; });
|
|
14
|
+
}
|
|
15
|
+
return cached;
|
|
16
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@didis/client",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Official TypeScript/JavaScript SDK for the DID.is public API: DID resolution with evidence, credentials, policies and agent trust",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
"files": [
|
|
16
16
|
"dist",
|
|
17
17
|
"README.md",
|
|
18
|
-
"LICENSE"
|
|
18
|
+
"LICENSE",
|
|
19
|
+
"CHANGELOG.md"
|
|
19
20
|
],
|
|
20
21
|
"sideEffects": false,
|
|
21
22
|
"engines": {
|