agentpassport-ts 0.1.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/src/trust.ts ADDED
@@ -0,0 +1,91 @@
1
+ // trust.ts
2
+ // TrustMiddleware and ScopeError — scope enforcement before capability dispatch
3
+
4
+ import { verifyAuthChain } from "./jwt.js";
5
+ import type { RevocationRegistry } from "./revocation.js";
6
+
7
+ export class ScopeError extends Error {
8
+ constructor(
9
+ public readonly capability: string,
10
+ public readonly required: string[],
11
+ public readonly granted: string[]
12
+ ) {
13
+ const missing = required.filter((s) => !granted.includes(s) && !granted.includes("*"));
14
+ super(
15
+ `Scope denied for capability "${capability}": ` +
16
+ `requires [${required.join(", ")}], missing [${missing.join(", ")}]`
17
+ );
18
+ this.name = "ScopeError";
19
+ }
20
+ }
21
+
22
+ export class TrustMiddleware {
23
+ constructor(
24
+ private readonly agentDid: string,
25
+ private readonly knownPublicKeys: Map<string, Uint8Array>,
26
+ private readonly capabilityScopes: Map<string, string[]>,
27
+ private readonly revocationRegistry?: RevocationRegistry
28
+ ) {}
29
+
30
+ /**
31
+ * Check that the auth chain grants the required scope for a capability.
32
+ *
33
+ * - No requires declared → always passes (backward compat)
34
+ * - Empty auth chain + requires declared → ScopeError
35
+ * - Scope matching: exact string OR "*" wildcard only
36
+ */
37
+ check(authChain: string[], capabilityName: string): void {
38
+ const required = this.capabilityScopes.get(capabilityName);
39
+ if (!required || required.length === 0) return; // no scope required
40
+
41
+ // Collect granted scopes from all tokens whose sub == agentDid
42
+ // and whose issuer is trusted
43
+ const granted = new Set<string>();
44
+
45
+ for (const token of authChain) {
46
+ try {
47
+ // Quick decode without verification to check sub
48
+ const parts = token.split(".");
49
+ if (parts.length !== 3) continue;
50
+ const claims = JSON.parse(
51
+ new TextDecoder().decode(
52
+ (() => {
53
+ const s = parts[1].replace(/-/g, "+").replace(/_/g, "/");
54
+ const padding = (4 - (s.length % 4)) % 4;
55
+ const binary = atob(s + "=".repeat(padding));
56
+ const bytes = new Uint8Array(binary.length);
57
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
58
+ return bytes;
59
+ })()
60
+ )
61
+ );
62
+
63
+ if (claims.sub !== this.agentDid) continue;
64
+ if (!this.knownPublicKeys.has(claims.iss)) continue;
65
+
66
+ // Verify this token cryptographically
67
+ const valid = verifyAuthChain({
68
+ chain: [token],
69
+ expectedSubject: this.agentDid,
70
+ knownPublicKeys: this.knownPublicKeys,
71
+ revocationRegistry: this.revocationRegistry,
72
+ });
73
+
74
+ if (valid && Array.isArray(claims.scope)) {
75
+ for (const s of claims.scope) granted.add(s as string);
76
+ }
77
+ } catch {
78
+ // malformed token — skip
79
+ }
80
+ }
81
+
82
+ // Wildcard grants everything
83
+ if (granted.has("*")) return;
84
+
85
+ // Check each required scope
86
+ const missing = required.filter((s) => !granted.has(s));
87
+ if (missing.length > 0) {
88
+ throw new ScopeError(capabilityName, required, Array.from(granted));
89
+ }
90
+ }
91
+ }
package/src/types.ts ADDED
@@ -0,0 +1,59 @@
1
+ // types.ts
2
+ // Core agentpassport types — wire-compatible with Python SDK
3
+
4
+ export type TaskState =
5
+ | "created"
6
+ | "delegated"
7
+ | "accepted"
8
+ | "running"
9
+ | "completed"
10
+ | "failed"
11
+ | "cancelled";
12
+
13
+ export interface Intent {
14
+ type: string;
15
+ params: Record<string, unknown>;
16
+ }
17
+
18
+ export interface Constraints {
19
+ budget_credits: number;
20
+ deadline_ms?: number;
21
+ max_delegations: number;
22
+ allowed_capabilities: string[];
23
+ denied_capabilities: string[];
24
+ }
25
+
26
+ export interface TaskEnvelope {
27
+ version: "1.0";
28
+ id: string;
29
+ parent_id?: string;
30
+ intent: Intent;
31
+ constraints: Constraints;
32
+ /** List of EdDSA JWT strings forming the delegation chain */
33
+ auth_chain: string[];
34
+ trace_id: string;
35
+ state: TaskState;
36
+ }
37
+
38
+ /** Convenience factory for creating a new TaskEnvelope. */
39
+ export function createTask(
40
+ intent: Intent,
41
+ overrides: Partial<Omit<TaskEnvelope, "version" | "intent">> = {}
42
+ ): TaskEnvelope {
43
+ return {
44
+ version: "1.0",
45
+ id: crypto.randomUUID(),
46
+ intent,
47
+ constraints: {
48
+ budget_credits: 100,
49
+ max_delegations: 5,
50
+ allowed_capabilities: [],
51
+ denied_capabilities: [],
52
+ ...overrides.constraints,
53
+ },
54
+ auth_chain: [],
55
+ trace_id: crypto.randomUUID(),
56
+ state: "created",
57
+ ...overrides,
58
+ };
59
+ }
@@ -0,0 +1,137 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { Agent, ScopeError } from "../src/agent.js";
3
+ import { generateKeypair, didFromPublicKey } from "../src/identity.js";
4
+ import { signDelegation } from "../src/jwt.js";
5
+ import { createTask } from "../src/types.js";
6
+
7
+ function makeParty() {
8
+ const kp = generateKeypair();
9
+ const did = didFromPublicKey(kp.publicKey);
10
+ return { kp, did };
11
+ }
12
+
13
+ describe("Agent", () => {
14
+ it("generates a DID on construction", () => {
15
+ const agent = new Agent("test-agent");
16
+ expect(agent.did).toMatch(/^did:key:z/);
17
+ });
18
+
19
+ it("two agents have distinct DIDs", () => {
20
+ const a = new Agent("agent-a");
21
+ const b = new Agent("agent-b");
22
+ expect(a.did).not.toBe(b.did);
23
+ });
24
+
25
+ it("handles a task with no scope requirement", async () => {
26
+ const agent = new Agent("agent");
27
+ agent.capability("echo", {}, async (task) => ({
28
+ echoed: task.intent.params,
29
+ }));
30
+
31
+ const task = createTask({ type: "echo", params: { msg: "hello" } });
32
+ const result = await agent.handle(task);
33
+ expect(result).toEqual({ echoed: { msg: "hello" } });
34
+ });
35
+
36
+ it("throws for unknown capability", async () => {
37
+ const agent = new Agent("agent");
38
+ const task = createTask({ type: "unknown", params: {} });
39
+ await expect(agent.handle(task)).rejects.toThrow("No handler for capability: unknown");
40
+ });
41
+
42
+ it("passes scope check when auth chain grants required scope", async () => {
43
+ const issuer = makeParty();
44
+ const agent = new Agent("agent");
45
+
46
+ agent.trustKeys({ [issuer.did]: issuer.kp.publicKey });
47
+
48
+ const token = signDelegation({
49
+ issuerPrivateKey: issuer.kp.privateKey,
50
+ issuerDid: issuer.did,
51
+ subjectDid: agent.did,
52
+ scope: ["read:db:customers"],
53
+ });
54
+
55
+ agent.capability(
56
+ "queryCustomers",
57
+ { requires: ["read:db:customers"] },
58
+ async () => ({ ok: true })
59
+ );
60
+
61
+ const task = createTask(
62
+ { type: "queryCustomers", params: {} },
63
+ { auth_chain: [token] }
64
+ );
65
+
66
+ const result = await agent.handle(task);
67
+ expect(result).toEqual({ ok: true });
68
+ });
69
+
70
+ it("throws ScopeError when scope is missing", async () => {
71
+ const agent = new Agent("agent");
72
+ agent.capability(
73
+ "queryCustomers",
74
+ { requires: ["read:db:customers"] },
75
+ async () => ({ ok: true })
76
+ );
77
+
78
+ const task = createTask({ type: "queryCustomers", params: {} });
79
+ await expect(agent.handle(task)).rejects.toThrow(ScopeError);
80
+ });
81
+
82
+ it("throws ScopeError for empty auth chain with required scope", async () => {
83
+ const agent = new Agent("agent");
84
+ agent.capability(
85
+ "queryCustomers",
86
+ { requires: ["read:db:customers"] },
87
+ async () => ({ ok: true })
88
+ );
89
+
90
+ const task = createTask(
91
+ { type: "queryCustomers", params: {} },
92
+ { auth_chain: [] }
93
+ );
94
+ await expect(agent.handle(task)).rejects.toThrow(ScopeError);
95
+ });
96
+
97
+ it("delegate appends a JWT to the auth chain", () => {
98
+ const agentA = new Agent("agent-a");
99
+ const agentB = new Agent("agent-b");
100
+
101
+ const task = createTask({ type: "echo", params: {} });
102
+ expect(task.auth_chain).toHaveLength(0);
103
+
104
+ const delegated = agentA.delegate(task, { targetDid: agentB.did });
105
+ expect(delegated.auth_chain).toHaveLength(1);
106
+ expect(delegated.state).toBe("delegated");
107
+ });
108
+
109
+ it("delegate preserves existing auth chain entries", () => {
110
+ const agentA = new Agent("agent-a");
111
+ const agentB = new Agent("agent-b");
112
+ const agentC = new Agent("agent-c");
113
+
114
+ const task = createTask({ type: "echo", params: {} });
115
+ const step1 = agentA.delegate(task, { targetDid: agentB.did });
116
+ const step2 = agentB.delegate(step1, { targetDid: agentC.did });
117
+
118
+ expect(step2.auth_chain).toHaveLength(2);
119
+ });
120
+
121
+ it("trustKeys accepts a plain object", () => {
122
+ const issuer = makeParty();
123
+ const agent = new Agent("agent");
124
+ // Should not throw
125
+ expect(() =>
126
+ agent.trustKeys({ [issuer.did]: issuer.kp.publicKey })
127
+ ).not.toThrow();
128
+ });
129
+
130
+ it("trustKeys accepts a Map", () => {
131
+ const issuer = makeParty();
132
+ const agent = new Agent("agent");
133
+ expect(() =>
134
+ agent.trustKeys(new Map([[issuer.did, issuer.kp.publicKey]]))
135
+ ).not.toThrow();
136
+ });
137
+ });
@@ -0,0 +1,92 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ generateKeypair,
4
+ keypairFromSeed,
5
+ didFromPublicKey,
6
+ parseDid,
7
+ base58btcEncode,
8
+ base58btcDecode,
9
+ } from "../src/identity.js";
10
+
11
+ describe("base58btc", () => {
12
+ it("round-trips arbitrary bytes", () => {
13
+ const data = new Uint8Array([0x00, 0x01, 0xde, 0xad, 0xbe, 0xef]);
14
+ expect(base58btcDecode(base58btcEncode(data))).toEqual(data);
15
+ });
16
+
17
+ it("preserves leading zero bytes", () => {
18
+ const data = new Uint8Array([0x00, 0x00, 0x01]);
19
+ const encoded = base58btcEncode(data);
20
+ expect(encoded.startsWith("11")).toBe(true);
21
+ expect(base58btcDecode(encoded)).toEqual(data);
22
+ });
23
+
24
+ it("rejects invalid character", () => {
25
+ expect(() => base58btcDecode("0OIl")).toThrow("Invalid base58btc character");
26
+ });
27
+ });
28
+
29
+ describe("generateKeypair", () => {
30
+ it("returns a 64-byte privateKey and 32-byte publicKey", () => {
31
+ const kp = generateKeypair();
32
+ expect(kp.privateKey).toHaveLength(64);
33
+ expect(kp.publicKey).toHaveLength(32);
34
+ });
35
+
36
+ it("privateKey[32:64] equals publicKey", () => {
37
+ const kp = generateKeypair();
38
+ expect(kp.privateKey.slice(32)).toEqual(kp.publicKey);
39
+ });
40
+
41
+ it("generates distinct keypairs each call", () => {
42
+ const a = generateKeypair();
43
+ const b = generateKeypair();
44
+ expect(a.publicKey).not.toEqual(b.publicKey);
45
+ });
46
+ });
47
+
48
+ describe("keypairFromSeed", () => {
49
+ it("derives same keypair from same seed", () => {
50
+ const seed = new Uint8Array(32).fill(42);
51
+ const a = keypairFromSeed(seed);
52
+ const b = keypairFromSeed(seed);
53
+ expect(a.publicKey).toEqual(b.publicKey);
54
+ });
55
+
56
+ it("rejects seed that is not 32 bytes", () => {
57
+ expect(() => keypairFromSeed(new Uint8Array(16))).toThrow("Seed must be 32 bytes");
58
+ });
59
+ });
60
+
61
+ describe("didFromPublicKey / parseDid", () => {
62
+ it("produces a did:key:z... string", () => {
63
+ const { publicKey } = generateKeypair();
64
+ const did = didFromPublicKey(publicKey);
65
+ expect(did).toMatch(/^did:key:z[1-9A-HJ-NP-Za-km-z]+$/);
66
+ });
67
+
68
+ it("round-trips public key through DID", () => {
69
+ const { publicKey } = generateKeypair();
70
+ const did = didFromPublicKey(publicKey);
71
+ expect(parseDid(did)).toEqual(publicKey);
72
+ });
73
+
74
+ it("parseDid rejects non-did:key strings", () => {
75
+ expect(() => parseDid("did:web:example.com")).toThrow("Invalid did:key DID");
76
+ });
77
+
78
+ it("parseDid rejects DID with wrong multicodec prefix", () => {
79
+ // Manually craft a DID with wrong prefix (0x1200 instead of 0xed01)
80
+ const fakeKey = new Uint8Array(34);
81
+ fakeKey[0] = 0x12;
82
+ fakeKey[1] = 0x00;
83
+ const fakeDid = `did:key:z${base58btcEncode(fakeKey)}`;
84
+ expect(() => parseDid(fakeDid)).toThrow("0xed01 multicodec prefix");
85
+ });
86
+
87
+ it("two distinct keypairs produce distinct DIDs", () => {
88
+ const a = generateKeypair();
89
+ const b = generateKeypair();
90
+ expect(didFromPublicKey(a.publicKey)).not.toBe(didFromPublicKey(b.publicKey));
91
+ });
92
+ });
@@ -0,0 +1,222 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ signDelegation,
4
+ verifyAuthChain,
5
+ decodeJwtClaims,
6
+ } from "../src/jwt.js";
7
+ import { generateKeypair, didFromPublicKey } from "../src/identity.js";
8
+ import { InMemoryRevocationRegistry } from "../src/revocation.js";
9
+
10
+ function makeParty() {
11
+ const kp = generateKeypair();
12
+ const did = didFromPublicKey(kp.publicKey);
13
+ return { kp, did };
14
+ }
15
+
16
+ describe("signDelegation", () => {
17
+ it("returns a 3-part JWT string", () => {
18
+ const issuer = makeParty();
19
+ const subject = makeParty();
20
+ const token = signDelegation({
21
+ issuerPrivateKey: issuer.kp.privateKey,
22
+ issuerDid: issuer.did,
23
+ subjectDid: subject.did,
24
+ scope: ["read:db:customers"],
25
+ });
26
+ expect(token.split(".")).toHaveLength(3);
27
+ });
28
+
29
+ it("claims contain required fields", () => {
30
+ const issuer = makeParty();
31
+ const subject = makeParty();
32
+ const token = signDelegation({
33
+ issuerPrivateKey: issuer.kp.privateKey,
34
+ issuerDid: issuer.did,
35
+ subjectDid: subject.did,
36
+ scope: ["write:api:stripe"],
37
+ maxDelegations: 2,
38
+ });
39
+ const claims = decodeJwtClaims(token);
40
+ expect(claims["iss"]).toBe(issuer.did);
41
+ expect(claims["sub"]).toBe(subject.did);
42
+ expect(claims["scope"]).toEqual(["write:api:stripe"]);
43
+ expect(claims["max_delegations"]).toBe(2);
44
+ expect(typeof claims["jti"]).toBe("string");
45
+ expect(typeof claims["iat"]).toBe("number");
46
+ expect(typeof claims["exp"]).toBe("number");
47
+ });
48
+
49
+ it("exp = iat + ttlSeconds", () => {
50
+ const issuer = makeParty();
51
+ const subject = makeParty();
52
+ const token = signDelegation({
53
+ issuerPrivateKey: issuer.kp.privateKey,
54
+ issuerDid: issuer.did,
55
+ subjectDid: subject.did,
56
+ scope: ["*"],
57
+ ttlSeconds: 7200,
58
+ });
59
+ const claims = decodeJwtClaims(token);
60
+ expect((claims["exp"] as number) - (claims["iat"] as number)).toBe(7200);
61
+ });
62
+ });
63
+
64
+ describe("verifyAuthChain", () => {
65
+ it("returns true for a valid single-hop chain", () => {
66
+ const issuer = makeParty();
67
+ const subject = makeParty();
68
+ const token = signDelegation({
69
+ issuerPrivateKey: issuer.kp.privateKey,
70
+ issuerDid: issuer.did,
71
+ subjectDid: subject.did,
72
+ scope: ["read:db:customers"],
73
+ });
74
+ const result = verifyAuthChain({
75
+ chain: [token],
76
+ expectedSubject: subject.did,
77
+ knownPublicKeys: new Map([[issuer.did, issuer.kp.publicKey]]),
78
+ });
79
+ expect(result).toBe(true);
80
+ });
81
+
82
+ it("returns false for empty chain", () => {
83
+ const subject = makeParty();
84
+ expect(
85
+ verifyAuthChain({
86
+ chain: [],
87
+ expectedSubject: subject.did,
88
+ knownPublicKeys: new Map(),
89
+ })
90
+ ).toBe(false);
91
+ });
92
+
93
+ it("returns false when subject does not match", () => {
94
+ const issuer = makeParty();
95
+ const subject = makeParty();
96
+ const other = makeParty();
97
+ const token = signDelegation({
98
+ issuerPrivateKey: issuer.kp.privateKey,
99
+ issuerDid: issuer.did,
100
+ subjectDid: subject.did,
101
+ scope: ["read:db:customers"],
102
+ });
103
+ expect(
104
+ verifyAuthChain({
105
+ chain: [token],
106
+ expectedSubject: other.did,
107
+ knownPublicKeys: new Map([[issuer.did, issuer.kp.publicKey]]),
108
+ })
109
+ ).toBe(false);
110
+ });
111
+
112
+ it("returns false when issuer is not in known keys", () => {
113
+ const issuer = makeParty();
114
+ const subject = makeParty();
115
+ const token = signDelegation({
116
+ issuerPrivateKey: issuer.kp.privateKey,
117
+ issuerDid: issuer.did,
118
+ subjectDid: subject.did,
119
+ scope: ["read:db:customers"],
120
+ });
121
+ expect(
122
+ verifyAuthChain({
123
+ chain: [token],
124
+ expectedSubject: subject.did,
125
+ knownPublicKeys: new Map(), // issuer not trusted
126
+ })
127
+ ).toBe(false);
128
+ });
129
+
130
+ it("returns false when signature is tampered", () => {
131
+ const issuer = makeParty();
132
+ const subject = makeParty();
133
+ const token = signDelegation({
134
+ issuerPrivateKey: issuer.kp.privateKey,
135
+ issuerDid: issuer.did,
136
+ subjectDid: subject.did,
137
+ scope: ["read:db:customers"],
138
+ });
139
+ const parts = token.split(".");
140
+ // Tamper the payload
141
+ const tampered = `${parts[0]}.${parts[1]}AAAA.${parts[2]}`;
142
+ expect(
143
+ verifyAuthChain({
144
+ chain: [tampered],
145
+ expectedSubject: subject.did,
146
+ knownPublicKeys: new Map([[issuer.did, issuer.kp.publicKey]]),
147
+ })
148
+ ).toBe(false);
149
+ });
150
+
151
+ it("returns false for an expired token", () => {
152
+ const issuer = makeParty();
153
+ const subject = makeParty();
154
+ const token = signDelegation({
155
+ issuerPrivateKey: issuer.kp.privateKey,
156
+ issuerDid: issuer.did,
157
+ subjectDid: subject.did,
158
+ scope: ["read:db:customers"],
159
+ ttlSeconds: -10, // already expired
160
+ });
161
+ expect(
162
+ verifyAuthChain({
163
+ chain: [token],
164
+ expectedSubject: subject.did,
165
+ knownPublicKeys: new Map([[issuer.did, issuer.kp.publicKey]]),
166
+ })
167
+ ).toBe(false);
168
+ });
169
+
170
+ it("returns false when jti is revoked", () => {
171
+ const issuer = makeParty();
172
+ const subject = makeParty();
173
+ const token = signDelegation({
174
+ issuerPrivateKey: issuer.kp.privateKey,
175
+ issuerDid: issuer.did,
176
+ subjectDid: subject.did,
177
+ scope: ["read:db:customers"],
178
+ });
179
+ const claims = decodeJwtClaims(token);
180
+ const registry = new InMemoryRevocationRegistry();
181
+ registry.revoke(claims["jti"] as string);
182
+
183
+ expect(
184
+ verifyAuthChain({
185
+ chain: [token],
186
+ expectedSubject: subject.did,
187
+ knownPublicKeys: new Map([[issuer.did, issuer.kp.publicKey]]),
188
+ revocationRegistry: registry,
189
+ })
190
+ ).toBe(false);
191
+ });
192
+
193
+ it("verifies a two-hop chain", () => {
194
+ const root = makeParty();
195
+ const middle = makeParty();
196
+ const leaf = makeParty();
197
+
198
+ const hop1 = signDelegation({
199
+ issuerPrivateKey: root.kp.privateKey,
200
+ issuerDid: root.did,
201
+ subjectDid: middle.did,
202
+ scope: ["read:db:customers"],
203
+ });
204
+ const hop2 = signDelegation({
205
+ issuerPrivateKey: middle.kp.privateKey,
206
+ issuerDid: middle.did,
207
+ subjectDid: leaf.did,
208
+ scope: ["read:db:customers"],
209
+ });
210
+
211
+ expect(
212
+ verifyAuthChain({
213
+ chain: [hop1, hop2],
214
+ expectedSubject: leaf.did,
215
+ knownPublicKeys: new Map([
216
+ [root.did, root.kp.publicKey],
217
+ [middle.did, middle.kp.publicKey],
218
+ ]),
219
+ })
220
+ ).toBe(true);
221
+ });
222
+ });
@@ -0,0 +1,35 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { InMemoryRevocationRegistry } from "../src/revocation.js";
3
+
4
+ describe("InMemoryRevocationRegistry", () => {
5
+ it("isRevoked returns false for unknown jti", () => {
6
+ const reg = new InMemoryRevocationRegistry();
7
+ expect(reg.isRevoked("some-jti")).toBe(false);
8
+ });
9
+
10
+ it("isRevoked returns true after revoke()", () => {
11
+ const reg = new InMemoryRevocationRegistry();
12
+ reg.revoke("jti-abc");
13
+ expect(reg.isRevoked("jti-abc")).toBe(true);
14
+ });
15
+
16
+ it("revoking one jti does not affect others", () => {
17
+ const reg = new InMemoryRevocationRegistry();
18
+ reg.revoke("jti-1");
19
+ expect(reg.isRevoked("jti-2")).toBe(false);
20
+ });
21
+
22
+ it("revoking same jti twice is idempotent", () => {
23
+ const reg = new InMemoryRevocationRegistry();
24
+ reg.revoke("jti-x");
25
+ reg.revoke("jti-x");
26
+ expect(reg.isRevoked("jti-x")).toBe(true);
27
+ });
28
+
29
+ it("each instance has independent state", () => {
30
+ const a = new InMemoryRevocationRegistry();
31
+ const b = new InMemoryRevocationRegistry();
32
+ a.revoke("shared-jti");
33
+ expect(b.isRevoked("shared-jti")).toBe(false);
34
+ });
35
+ });