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/README.md +52 -0
- package/package.json +29 -0
- package/pyproject.toml +6 -0
- package/src/agent.ts +140 -0
- package/src/identity.ts +123 -0
- package/src/index.ts +52 -0
- package/src/jwt.ts +197 -0
- package/src/revocation.ts +19 -0
- package/src/trust.ts +91 -0
- package/src/types.ts +59 -0
- package/tests/agent.test.ts +137 -0
- package/tests/identity.test.ts +92 -0
- package/tests/jwt.test.ts +222 -0
- package/tests/revocation.test.ts +35 -0
- package/tests/trust.test.ts +155 -0
- package/tsconfig.json +19 -0
- package/vitest.config.ts +8 -0
package/README.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# agentpassport-ts — TypeScript SDK
|
|
2
|
+
|
|
3
|
+
Wire-compatible TypeScript SDK for agentpassport. Same four primitives as the Python SDK — cross-language trust chains work out of the box.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quickstart
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import { Agent, InMemoryRevocationRegistry, ScopeError } from "agentpassport-ts"
|
|
15
|
+
|
|
16
|
+
const revocationRegistry = new InMemoryRevocationRegistry()
|
|
17
|
+
const agent = new Agent("ts-agent", { privateKey, revocationRegistry })
|
|
18
|
+
|
|
19
|
+
// Trust a Python orchestrator
|
|
20
|
+
agent.trustKeys({ [orchestratorDid]: orchestratorPublicKey })
|
|
21
|
+
|
|
22
|
+
// Declare required scope
|
|
23
|
+
agent.capability("queryCustomers", { requires: ["read:db:customers"] }, async (task) => {
|
|
24
|
+
return { customers: [...] }
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
// Handle incoming task (verifies auth chain automatically)
|
|
28
|
+
const result = await agent.handle(task)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Primitives
|
|
32
|
+
|
|
33
|
+
- **Identity** — Ed25519 keypair → `did:key:z<base58btc>` DID (same format as Python)
|
|
34
|
+
- **Auth chain** — sign/verify EdDSA JWTs, `verifyAuthChain()` mirrors Python exactly
|
|
35
|
+
- **TrustMiddleware** — scope declaration + enforcement, `ScopeError` on violation
|
|
36
|
+
- **RevocationRegistry** — `InMemoryRevocationRegistry` interface
|
|
37
|
+
|
|
38
|
+
## Wire Compatibility
|
|
39
|
+
|
|
40
|
+
JWT format is identical to the Python SDK:
|
|
41
|
+
- Header: `{"alg":"EdDSA","crv":"Ed25519"}`
|
|
42
|
+
- Claims sorted alphabetically (matches Python's `sort_keys=True`)
|
|
43
|
+
- `did:key:z<base58btc>` with `0xed01` multicodec prefix
|
|
44
|
+
|
|
45
|
+
A Python orchestrator can sign a delegation JWT. A TypeScript agent can verify it independently.
|
|
46
|
+
|
|
47
|
+
## Development
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npm install
|
|
51
|
+
npm test
|
|
52
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "agentpassport-ts",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "TypeScript SDK for agentpassport — the AI passport layer",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc",
|
|
16
|
+
"test": "vitest run",
|
|
17
|
+
"test:watch": "vitest"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@noble/ed25519": "^2.1.0",
|
|
21
|
+
"@noble/hashes": "^1.4.0"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/node": "^22.0.0",
|
|
25
|
+
"tsx": "^4.21.0",
|
|
26
|
+
"typescript": "^5.5.0",
|
|
27
|
+
"vitest": "^2.0.0"
|
|
28
|
+
}
|
|
29
|
+
}
|
package/pyproject.toml
ADDED
package/src/agent.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// agent.ts
|
|
2
|
+
// Agent class with capability registration, trust middleware, and delegation
|
|
3
|
+
|
|
4
|
+
import { didFromPublicKey, generateKeypair, keypairFromSeed } from "./identity.js";
|
|
5
|
+
import { signDelegation } from "./jwt.js";
|
|
6
|
+
import { ScopeError, TrustMiddleware } from "./trust.js";
|
|
7
|
+
import type { RevocationRegistry } from "./revocation.js";
|
|
8
|
+
import type { TaskEnvelope } from "./types.js";
|
|
9
|
+
|
|
10
|
+
export type CapabilityHandler = (task: TaskEnvelope) => Promise<Record<string, unknown>>;
|
|
11
|
+
|
|
12
|
+
export interface CapabilityOptions {
|
|
13
|
+
requires?: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface DelegateOptions {
|
|
17
|
+
targetDid: string;
|
|
18
|
+
scope?: string[];
|
|
19
|
+
ttlSeconds?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class Agent {
|
|
23
|
+
readonly name: string;
|
|
24
|
+
readonly did: string;
|
|
25
|
+
|
|
26
|
+
private readonly _privateKey: Uint8Array;
|
|
27
|
+
private readonly _publicKey: Uint8Array;
|
|
28
|
+
private readonly _capabilities = new Map<string, CapabilityHandler>();
|
|
29
|
+
private readonly _capabilityScopes = new Map<string, string[]>();
|
|
30
|
+
private readonly _trustedKeys = new Map<string, Uint8Array>();
|
|
31
|
+
private readonly _trustMiddleware: TrustMiddleware;
|
|
32
|
+
|
|
33
|
+
constructor(
|
|
34
|
+
name: string,
|
|
35
|
+
opts: {
|
|
36
|
+
privateKey?: Uint8Array;
|
|
37
|
+
revocationRegistry?: RevocationRegistry;
|
|
38
|
+
} = {}
|
|
39
|
+
) {
|
|
40
|
+
this.name = name;
|
|
41
|
+
|
|
42
|
+
if (opts.privateKey) {
|
|
43
|
+
this._privateKey = opts.privateKey;
|
|
44
|
+
// If 64-byte, last 32 are the public key; if 32-byte seed, derive it
|
|
45
|
+
if (opts.privateKey.length === 64) {
|
|
46
|
+
this._publicKey = opts.privateKey.slice(32);
|
|
47
|
+
} else {
|
|
48
|
+
this._publicKey = keypairFromSeed(opts.privateKey).publicKey;
|
|
49
|
+
}
|
|
50
|
+
} else {
|
|
51
|
+
const kp = generateKeypair();
|
|
52
|
+
this._privateKey = kp.privateKey;
|
|
53
|
+
this._publicKey = kp.publicKey;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
this.did = didFromPublicKey(this._publicKey);
|
|
57
|
+
|
|
58
|
+
this._trustMiddleware = new TrustMiddleware(
|
|
59
|
+
this.did,
|
|
60
|
+
this._trustedKeys,
|
|
61
|
+
this._capabilityScopes,
|
|
62
|
+
opts.revocationRegistry
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Register a capability handler.
|
|
68
|
+
*
|
|
69
|
+
* @param name Capability name matched against task.intent.type
|
|
70
|
+
* @param options Optional { requires: string[] } for scope enforcement
|
|
71
|
+
* @param handler Async handler function
|
|
72
|
+
*/
|
|
73
|
+
capability(
|
|
74
|
+
name: string,
|
|
75
|
+
options: CapabilityOptions,
|
|
76
|
+
handler: CapabilityHandler
|
|
77
|
+
): this {
|
|
78
|
+
this._capabilities.set(name, handler);
|
|
79
|
+
if (options.requires && options.requires.length > 0) {
|
|
80
|
+
this._capabilityScopes.set(name, options.requires);
|
|
81
|
+
}
|
|
82
|
+
return this;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Register trusted issuer public keys for auth chain verification. */
|
|
86
|
+
trustKeys(keys: Map<string, Uint8Array> | Record<string, Uint8Array>): void {
|
|
87
|
+
const entries =
|
|
88
|
+
keys instanceof Map ? keys.entries() : Object.entries(keys);
|
|
89
|
+
for (const [did, pubKey] of entries) {
|
|
90
|
+
this._trustedKeys.set(did, pubKey);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Handle an incoming task.
|
|
96
|
+
* Runs scope check before dispatching to the capability handler.
|
|
97
|
+
* Throws ScopeError if the auth chain doesn't cover declared scope.
|
|
98
|
+
*/
|
|
99
|
+
async handle(task: TaskEnvelope): Promise<Record<string, unknown>> {
|
|
100
|
+
const handler = this._capabilities.get(task.intent.type);
|
|
101
|
+
if (!handler) {
|
|
102
|
+
throw new Error(`No handler for capability: ${task.intent.type}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Pre-execution scope check — throws ScopeError if violated
|
|
106
|
+
this._trustMiddleware.check(task.auth_chain, task.intent.type);
|
|
107
|
+
|
|
108
|
+
return handler(task);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Delegate a task to another agent by signing a new delegation JWT
|
|
113
|
+
* and appending it to the task's auth chain.
|
|
114
|
+
*/
|
|
115
|
+
delegate(
|
|
116
|
+
task: TaskEnvelope,
|
|
117
|
+
opts: DelegateOptions
|
|
118
|
+
): TaskEnvelope {
|
|
119
|
+
const token = signDelegation({
|
|
120
|
+
issuerPrivateKey: this._privateKey,
|
|
121
|
+
issuerDid: this.did,
|
|
122
|
+
subjectDid: opts.targetDid,
|
|
123
|
+
scope: opts.scope ?? ["*"],
|
|
124
|
+
ttlSeconds: opts.ttlSeconds ?? 3600,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
...task,
|
|
129
|
+
auth_chain: [...task.auth_chain, token],
|
|
130
|
+
state: "delegated",
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Expose this agent's public key (for use by others to trust this agent). */
|
|
135
|
+
get publicKey(): Uint8Array {
|
|
136
|
+
return this._publicKey;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export { ScopeError };
|
package/src/identity.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// identity.ts
|
|
2
|
+
// DID generation, Ed25519 keypair, base58btc — wire-compatible with Python SDK
|
|
3
|
+
|
|
4
|
+
import * as ed from "@noble/ed25519";
|
|
5
|
+
import { sha512 } from "@noble/hashes/sha512";
|
|
6
|
+
|
|
7
|
+
// @noble/ed25519 v2+ requires an explicit SHA-512 implementation
|
|
8
|
+
ed.etc.sha512Sync = (...m: Uint8Array[]) => sha512(...m);
|
|
9
|
+
|
|
10
|
+
// Base58btc alphabet — identical to Python SDK
|
|
11
|
+
const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
12
|
+
|
|
13
|
+
// Multicodec prefix for Ed25519 public key: varint-encoded 0xed01
|
|
14
|
+
const ED25519_PREFIX = Uint8Array.from([0xed, 0x01]);
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Base58btc (pure — no external dep)
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
export function base58btcEncode(data: Uint8Array): string {
|
|
21
|
+
let leadingZeros = 0;
|
|
22
|
+
for (const b of data) {
|
|
23
|
+
if (b !== 0) break;
|
|
24
|
+
leadingZeros++;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let n = 0n;
|
|
28
|
+
for (const b of data) {
|
|
29
|
+
n = n * 256n + BigInt(b);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const digits: string[] = [];
|
|
33
|
+
while (n > 0n) {
|
|
34
|
+
const rem = Number(n % 58n);
|
|
35
|
+
n /= 58n;
|
|
36
|
+
digits.push(BASE58_ALPHABET[rem]);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return "1".repeat(leadingZeros) + digits.reverse().join("");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function base58btcDecode(s: string): Uint8Array {
|
|
43
|
+
let leadingZeros = 0;
|
|
44
|
+
for (const c of s) {
|
|
45
|
+
if (c !== "1") break;
|
|
46
|
+
leadingZeros++;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let n = 0n;
|
|
50
|
+
for (const c of s) {
|
|
51
|
+
const idx = BASE58_ALPHABET.indexOf(c);
|
|
52
|
+
if (idx < 0) throw new Error(`Invalid base58btc character: ${c}`);
|
|
53
|
+
n = n * 58n + BigInt(idx);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const bytes: number[] = [];
|
|
57
|
+
while (n > 0n) {
|
|
58
|
+
bytes.push(Number(n % 256n));
|
|
59
|
+
n /= 256n;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const result = new Uint8Array(leadingZeros + bytes.length);
|
|
63
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
64
|
+
result[leadingZeros + i] = bytes[bytes.length - 1 - i];
|
|
65
|
+
}
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Keypair
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
export interface Keypair {
|
|
74
|
+
/** 64-byte seed+pubkey — matches Python: bytes(sk) + bytes(sk.verify_key) */
|
|
75
|
+
privateKey: Uint8Array;
|
|
76
|
+
/** 32-byte Ed25519 public key */
|
|
77
|
+
publicKey: Uint8Array;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function generateKeypair(): Keypair {
|
|
81
|
+
const seed = ed.utils.randomPrivateKey(); // 32-byte seed
|
|
82
|
+
const publicKey = ed.getPublicKey(seed);
|
|
83
|
+
// Mirror Python layout: 64 bytes = seed (32) + pubkey (32)
|
|
84
|
+
const privateKey = new Uint8Array(64);
|
|
85
|
+
privateKey.set(seed);
|
|
86
|
+
privateKey.set(publicKey, 32);
|
|
87
|
+
return { privateKey, publicKey };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Build a Keypair from a raw 32-byte seed (useful in tests). */
|
|
91
|
+
export function keypairFromSeed(seed: Uint8Array): Keypair {
|
|
92
|
+
if (seed.length !== 32) throw new Error("Seed must be 32 bytes");
|
|
93
|
+
const publicKey = ed.getPublicKey(seed);
|
|
94
|
+
const privateKey = new Uint8Array(64);
|
|
95
|
+
privateKey.set(seed);
|
|
96
|
+
privateKey.set(publicKey, 32);
|
|
97
|
+
return { privateKey, publicKey };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// DID
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
export function didFromPublicKey(publicKey: Uint8Array): string {
|
|
105
|
+
const prefixed = new Uint8Array(ED25519_PREFIX.length + publicKey.length);
|
|
106
|
+
prefixed.set(ED25519_PREFIX);
|
|
107
|
+
prefixed.set(publicKey, ED25519_PREFIX.length);
|
|
108
|
+
return `did:key:z${base58btcEncode(prefixed)}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function parseDid(did: string): Uint8Array {
|
|
112
|
+
if (!did.startsWith("did:key:z")) {
|
|
113
|
+
throw new Error(`Invalid did:key DID (expected did:key:z...): ${did}`);
|
|
114
|
+
}
|
|
115
|
+
const encoded = did.slice(9); // strip "did:key:z"
|
|
116
|
+
const prefixed = base58btcDecode(encoded);
|
|
117
|
+
if (prefixed[0] !== 0xed || prefixed[1] !== 0x01) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`DID does not contain an Ed25519 key (expected 0xed01 multicodec prefix): ${did}`
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
return prefixed.slice(2); // strip 2-byte multicodec prefix
|
|
123
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// index.ts
|
|
2
|
+
// Public exports for agentpassport-ts
|
|
3
|
+
|
|
4
|
+
export {
|
|
5
|
+
// identity
|
|
6
|
+
generateKeypair,
|
|
7
|
+
keypairFromSeed,
|
|
8
|
+
didFromPublicKey,
|
|
9
|
+
parseDid,
|
|
10
|
+
base58btcEncode,
|
|
11
|
+
base58btcDecode,
|
|
12
|
+
type Keypair,
|
|
13
|
+
} from "./identity.js";
|
|
14
|
+
|
|
15
|
+
export {
|
|
16
|
+
// jwt
|
|
17
|
+
signDelegation,
|
|
18
|
+
verifyAuthChain,
|
|
19
|
+
decodeJwtClaims,
|
|
20
|
+
type DelegationClaims,
|
|
21
|
+
type SignDelegationOptions,
|
|
22
|
+
type VerifyAuthChainOptions,
|
|
23
|
+
} from "./jwt.js";
|
|
24
|
+
|
|
25
|
+
export {
|
|
26
|
+
// revocation
|
|
27
|
+
InMemoryRevocationRegistry,
|
|
28
|
+
type RevocationRegistry,
|
|
29
|
+
} from "./revocation.js";
|
|
30
|
+
|
|
31
|
+
export {
|
|
32
|
+
// trust
|
|
33
|
+
TrustMiddleware,
|
|
34
|
+
ScopeError,
|
|
35
|
+
} from "./trust.js";
|
|
36
|
+
|
|
37
|
+
export {
|
|
38
|
+
// agent
|
|
39
|
+
Agent,
|
|
40
|
+
type CapabilityHandler,
|
|
41
|
+
type CapabilityOptions,
|
|
42
|
+
type DelegateOptions,
|
|
43
|
+
} from "./agent.js";
|
|
44
|
+
|
|
45
|
+
export {
|
|
46
|
+
// types
|
|
47
|
+
createTask,
|
|
48
|
+
type TaskEnvelope,
|
|
49
|
+
type TaskState,
|
|
50
|
+
type Intent,
|
|
51
|
+
type Constraints,
|
|
52
|
+
} from "./types.js";
|
package/src/jwt.ts
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// jwt.ts
|
|
2
|
+
// EdDSA JWT sign/verify and auth-chain verification — wire-compatible with Python SDK
|
|
3
|
+
|
|
4
|
+
import * as ed from "@noble/ed25519";
|
|
5
|
+
import { sha512 } from "@noble/hashes/sha512";
|
|
6
|
+
import { parseDid } from "./identity.js";
|
|
7
|
+
import type { RevocationRegistry } from "./revocation.js";
|
|
8
|
+
|
|
9
|
+
ed.etc.sha512Sync = (...m: Uint8Array[]) => sha512(...m);
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Base64url helpers (no external dep)
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
function b64urlEncode(data: Uint8Array): string {
|
|
16
|
+
// btoa works on binary strings
|
|
17
|
+
let binary = "";
|
|
18
|
+
for (let i = 0; i < data.length; i++) binary += String.fromCharCode(data[i]);
|
|
19
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function b64urlDecode(s: string): Uint8Array {
|
|
23
|
+
const padded = s.replace(/-/g, "+").replace(/_/g, "/");
|
|
24
|
+
const padding = (4 - (padded.length % 4)) % 4;
|
|
25
|
+
const base64 = padded + "=".repeat(padding);
|
|
26
|
+
const binary = atob(base64);
|
|
27
|
+
const bytes = new Uint8Array(binary.length);
|
|
28
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
29
|
+
return bytes;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// JWT header (constant — same as Python)
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
const JWT_HEADER_OBJ = { alg: "EdDSA", crv: "Ed25519" };
|
|
37
|
+
const JWT_HEADER = b64urlEncode(
|
|
38
|
+
new TextEncoder().encode(JSON.stringify(JWT_HEADER_OBJ))
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// JWT claims types
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
export interface DelegationClaims {
|
|
46
|
+
iss: string;
|
|
47
|
+
sub: string;
|
|
48
|
+
iat: number;
|
|
49
|
+
exp: number;
|
|
50
|
+
jti: string;
|
|
51
|
+
scope: string[];
|
|
52
|
+
max_delegations: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Internal helpers
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
function encodeJwt(claims: Record<string, unknown>, seed: Uint8Array): string {
|
|
60
|
+
// Sort keys — matches Python's sort_keys=True
|
|
61
|
+
const sorted = Object.fromEntries(
|
|
62
|
+
Object.keys(claims)
|
|
63
|
+
.sort()
|
|
64
|
+
.map((k) => [k, claims[k]])
|
|
65
|
+
);
|
|
66
|
+
const payload = b64urlEncode(
|
|
67
|
+
new TextEncoder().encode(JSON.stringify(sorted))
|
|
68
|
+
);
|
|
69
|
+
const signingInput = new TextEncoder().encode(`${JWT_HEADER}.${payload}`);
|
|
70
|
+
const sig = ed.sign(signingInput, seed);
|
|
71
|
+
return `${JWT_HEADER}.${payload}.${b64urlEncode(sig)}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function decodeJwtClaims(token: string): Record<string, unknown> {
|
|
75
|
+
const parts = token.split(".");
|
|
76
|
+
if (parts.length !== 3) throw new Error(`Malformed JWT: expected 3 parts, got ${parts.length}`);
|
|
77
|
+
return JSON.parse(new TextDecoder().decode(b64urlDecode(parts[1])));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function verifyJwtSignature(token: string, publicKeyBytes: Uint8Array): Record<string, unknown> {
|
|
81
|
+
const parts = token.split(".");
|
|
82
|
+
if (parts.length !== 3) throw new Error(`Malformed JWT: expected 3 parts, got ${parts.length}`);
|
|
83
|
+
|
|
84
|
+
const header = JSON.parse(new TextDecoder().decode(b64urlDecode(parts[0])));
|
|
85
|
+
if (header.alg !== "EdDSA") throw new Error(`Unsupported JWT algorithm: ${header.alg}`);
|
|
86
|
+
|
|
87
|
+
const signingInput = new TextEncoder().encode(`${parts[0]}.${parts[1]}`);
|
|
88
|
+
const sig = b64urlDecode(parts[2]);
|
|
89
|
+
|
|
90
|
+
const valid = ed.verify(sig, signingInput, publicKeyBytes);
|
|
91
|
+
if (!valid) throw new Error("Invalid JWT signature");
|
|
92
|
+
|
|
93
|
+
return JSON.parse(new TextDecoder().decode(b64urlDecode(parts[1])));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Public API
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
export interface SignDelegationOptions {
|
|
101
|
+
issuerPrivateKey: Uint8Array; // 64-byte (seed+pubkey) or 32-byte seed
|
|
102
|
+
issuerDid: string;
|
|
103
|
+
subjectDid: string;
|
|
104
|
+
scope: string[];
|
|
105
|
+
ttlSeconds?: number;
|
|
106
|
+
maxDelegations?: number;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function signDelegation(opts: SignDelegationOptions): string {
|
|
110
|
+
const {
|
|
111
|
+
issuerPrivateKey,
|
|
112
|
+
issuerDid,
|
|
113
|
+
subjectDid,
|
|
114
|
+
scope,
|
|
115
|
+
ttlSeconds = 3600,
|
|
116
|
+
maxDelegations = 0,
|
|
117
|
+
} = opts;
|
|
118
|
+
|
|
119
|
+
const now = Math.floor(Date.now() / 1000);
|
|
120
|
+
const exp = now + ttlSeconds;
|
|
121
|
+
const jti = crypto.randomUUID();
|
|
122
|
+
|
|
123
|
+
const claims: Record<string, unknown> = {
|
|
124
|
+
iss: issuerDid,
|
|
125
|
+
sub: subjectDid,
|
|
126
|
+
iat: now,
|
|
127
|
+
exp,
|
|
128
|
+
jti,
|
|
129
|
+
scope,
|
|
130
|
+
max_delegations: maxDelegations,
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// First 32 bytes are the seed — matches Python: seed = private_key[:32]
|
|
134
|
+
const seed = issuerPrivateKey.slice(0, 32);
|
|
135
|
+
return encodeJwt(claims, seed);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface VerifyAuthChainOptions {
|
|
139
|
+
chain: string[];
|
|
140
|
+
expectedSubject: string;
|
|
141
|
+
knownPublicKeys: Map<string, Uint8Array>;
|
|
142
|
+
revocationRegistry?: RevocationRegistry;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function verifyAuthChain(opts: VerifyAuthChainOptions): boolean {
|
|
146
|
+
const { chain, expectedSubject, knownPublicKeys, revocationRegistry } = opts;
|
|
147
|
+
|
|
148
|
+
if (chain.length === 0) return false;
|
|
149
|
+
|
|
150
|
+
const nowTs = Date.now() / 1000;
|
|
151
|
+
|
|
152
|
+
for (const token of chain) {
|
|
153
|
+
// Decode unverified claims first to find the issuer
|
|
154
|
+
let unverified: Record<string, unknown>;
|
|
155
|
+
try {
|
|
156
|
+
unverified = decodeJwtClaims(token);
|
|
157
|
+
} catch {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const issuer = unverified["iss"] as string | undefined;
|
|
162
|
+
const pubKeyBytes = issuer ? knownPublicKeys.get(issuer) : undefined;
|
|
163
|
+
if (!pubKeyBytes) return false;
|
|
164
|
+
|
|
165
|
+
// Cryptographic verification
|
|
166
|
+
let claims: Record<string, unknown>;
|
|
167
|
+
try {
|
|
168
|
+
claims = verifyJwtSignature(token, pubKeyBytes);
|
|
169
|
+
} catch {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Temporal validity
|
|
174
|
+
const iat = claims["iat"];
|
|
175
|
+
const exp = claims["exp"];
|
|
176
|
+
if (typeof iat !== "number" || typeof exp !== "number") return false;
|
|
177
|
+
if (iat > nowTs || nowTs > exp) return false;
|
|
178
|
+
|
|
179
|
+
// jti required
|
|
180
|
+
const jti = claims["jti"];
|
|
181
|
+
if (!jti || typeof jti !== "string") return false;
|
|
182
|
+
|
|
183
|
+
// Revocation check
|
|
184
|
+
if (revocationRegistry?.isRevoked(jti)) return false;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Final subject check
|
|
188
|
+
try {
|
|
189
|
+
const lastClaims = decodeJwtClaims(chain[chain.length - 1]);
|
|
190
|
+
return lastClaims["sub"] === expectedSubject;
|
|
191
|
+
} catch {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Decode JWT claims without verifying (useful for inspection). */
|
|
197
|
+
export { decodeJwtClaims };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// revocation.ts
|
|
2
|
+
// RevocationRegistry interface + InMemoryRevocationRegistry
|
|
3
|
+
|
|
4
|
+
export interface RevocationRegistry {
|
|
5
|
+
revoke(jti: string): void;
|
|
6
|
+
isRevoked(jti: string): boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class InMemoryRevocationRegistry implements RevocationRegistry {
|
|
10
|
+
private readonly _revoked = new Set<string>();
|
|
11
|
+
|
|
12
|
+
revoke(jti: string): void {
|
|
13
|
+
this._revoked.add(jti);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
isRevoked(jti: string): boolean {
|
|
17
|
+
return this._revoked.has(jti);
|
|
18
|
+
}
|
|
19
|
+
}
|