@mcp-i/core 1.1.1 → 1.2.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/dist/delegation/cascading-revocation.d.ts.map +1 -1
- package/dist/delegation/cascading-revocation.js +3 -1
- package/dist/delegation/cascading-revocation.js.map +1 -1
- package/dist/delegation/outbound-headers.d.ts +12 -12
- package/dist/delegation/outbound-headers.d.ts.map +1 -1
- package/dist/delegation/outbound-headers.js +12 -12
- package/dist/delegation/outbound-headers.js.map +1 -1
- package/dist/delegation/outbound-proof.d.ts +1 -1
- package/dist/delegation/outbound-proof.js +1 -1
- package/dist/delegation/statuslist-manager.d.ts +3 -0
- package/dist/delegation/statuslist-manager.d.ts.map +1 -1
- package/dist/delegation/statuslist-manager.js +13 -0
- package/dist/delegation/statuslist-manager.js.map +1 -1
- package/dist/middleware/with-mcpi-server.d.ts +1 -1
- package/dist/middleware/with-mcpi-server.d.ts.map +1 -1
- package/dist/middleware/with-mcpi-server.js.map +1 -1
- package/dist/middleware/with-mcpi.d.ts +14 -0
- package/dist/middleware/with-mcpi.d.ts.map +1 -1
- package/dist/middleware/with-mcpi.js +12 -0
- package/dist/middleware/with-mcpi.js.map +1 -1
- package/package.json +3 -2
- package/src/__tests__/audit/canonicalization-integrity.test.ts +243 -0
- package/src/__tests__/audit/graph-revocation-roundtrip.test.ts +280 -0
- package/src/__tests__/audit/helpers/crypto-helpers.ts +245 -0
- package/src/__tests__/audit/proof-boundary.test.ts +269 -0
- package/src/__tests__/audit/statuslist-bitstring-roundtrip.test.ts +135 -0
- package/src/__tests__/audit/vc-roundtrip.test.ts +290 -0
- package/src/delegation/__tests__/outbound-headers.test.ts +16 -16
- package/src/delegation/__tests__/transitive-access.test.ts +1233 -0
- package/src/delegation/__tests__/vc-issuer.integration.test.ts +136 -0
- package/src/delegation/__tests__/vc-jwt.test.ts +318 -0
- package/src/delegation/__tests__/vc-verifier.integration.test.ts +199 -0
- package/src/delegation/cascading-revocation.ts +3 -1
- package/src/delegation/outbound-headers.ts +16 -16
- package/src/delegation/outbound-proof.ts +1 -1
- package/src/delegation/statuslist-manager.ts +17 -0
- package/src/middleware/with-mcpi-server.ts +1 -5
- package/src/middleware/with-mcpi.ts +29 -0
- package/src/proof/__tests__/verifier.integration.test.ts +181 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Test Helpers for Audit Tests
|
|
3
|
+
*
|
|
4
|
+
* Provides real (non-mocked) crypto, clock, and signing utilities
|
|
5
|
+
* for round-trip and boundary testing. All helpers use NodeCryptoProvider
|
|
6
|
+
* for actual Ed25519 operations.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as zlib from 'node:zlib';
|
|
10
|
+
import { NodeCryptoProvider } from '../../utils/node-crypto-provider.js';
|
|
11
|
+
import { MemoryIdentityProvider, MemoryNonceCacheProvider } from '../../../providers/memory.js';
|
|
12
|
+
import { ClockProvider, FetchProvider } from '../../../providers/base.js';
|
|
13
|
+
import type { AgentIdentity } from '../../../providers/base.js';
|
|
14
|
+
import type { Proof, StatusList2021Credential, DelegationRecord } from '../../../types/protocol.js';
|
|
15
|
+
import type { VCSigningFunction } from '../../../delegation/vc-issuer.js';
|
|
16
|
+
import type { SignatureVerificationFunction, DIDDocument } from '../../../delegation/vc-verifier.js';
|
|
17
|
+
import { canonicalizeJSON } from '../../../delegation/utils.js';
|
|
18
|
+
import { createDidKeyResolver } from '../../../delegation/did-key-resolver.js';
|
|
19
|
+
import type { CompressionFunction, DecompressionFunction } from '../../../delegation/bitstring.js';
|
|
20
|
+
import { StatusList2021Manager } from '../../../delegation/statuslist-manager.js';
|
|
21
|
+
import { MemoryStatusListStorage } from '../../../delegation/storage/memory-statuslist-storage.js';
|
|
22
|
+
|
|
23
|
+
// ── Crypto ──────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
export function createRealCryptoProvider(): NodeCryptoProvider {
|
|
26
|
+
return new NodeCryptoProvider();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function createRealIdentity(crypto: NodeCryptoProvider): Promise<AgentIdentity> {
|
|
30
|
+
const provider = new MemoryIdentityProvider(crypto);
|
|
31
|
+
return provider.getIdentity();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── Clock Providers ─────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
export class RealClockProvider extends ClockProvider {
|
|
37
|
+
now(): number {
|
|
38
|
+
return Date.now();
|
|
39
|
+
}
|
|
40
|
+
isWithinSkew(timestampMs: number, skewSeconds: number): boolean {
|
|
41
|
+
return Math.abs(Date.now() - timestampMs) <= skewSeconds * 1000;
|
|
42
|
+
}
|
|
43
|
+
hasExpired(expiresAt: number): boolean {
|
|
44
|
+
return Date.now() > expiresAt;
|
|
45
|
+
}
|
|
46
|
+
calculateExpiry(ttlSeconds: number): number {
|
|
47
|
+
return Date.now() + ttlSeconds * 1000;
|
|
48
|
+
}
|
|
49
|
+
format(timestamp: number): string {
|
|
50
|
+
return new Date(timestamp).toISOString();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Clock provider with a controllable "now" for precise boundary testing.
|
|
56
|
+
* `setNow(ms)` moves the clock to an exact millisecond.
|
|
57
|
+
*/
|
|
58
|
+
export class ControllableClockProvider extends ClockProvider {
|
|
59
|
+
private currentMs: number;
|
|
60
|
+
|
|
61
|
+
constructor(nowMs: number = Date.now()) {
|
|
62
|
+
super();
|
|
63
|
+
this.currentMs = nowMs;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
setNow(ms: number): void {
|
|
67
|
+
this.currentMs = ms;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
advance(ms: number): void {
|
|
71
|
+
this.currentMs += ms;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
now(): number {
|
|
75
|
+
return this.currentMs;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
isWithinSkew(timestampMs: number, skewSeconds: number): boolean {
|
|
79
|
+
return Math.abs(this.currentMs - timestampMs) <= skewSeconds * 1000;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
hasExpired(expiresAt: number): boolean {
|
|
83
|
+
return this.currentMs > expiresAt;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
calculateExpiry(ttlSeconds: number): number {
|
|
87
|
+
return this.currentMs + ttlSeconds * 1000;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
format(timestamp: number): string {
|
|
91
|
+
return new Date(timestamp).toISOString();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── Fetch Provider ──────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
export class RealFetchProvider extends FetchProvider {
|
|
98
|
+
private didResolver = createDidKeyResolver();
|
|
99
|
+
|
|
100
|
+
async resolveDID(did: string): Promise<DIDDocument | null> {
|
|
101
|
+
return this.didResolver.resolve(did);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async fetchStatusList(_url: string): Promise<StatusList2021Credential | null> {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async fetchDelegationChain(_id: string): Promise<DelegationRecord[]> {
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async fetch(_url: string, _options?: unknown): Promise<Response> {
|
|
113
|
+
throw new Error('Not implemented');
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Signing & Verification ──────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Creates a real VCSigningFunction that produces Ed25519Signature2020 proofs.
|
|
121
|
+
* The signature is over the canonicalized VC (the `canonicalVC` string passed in).
|
|
122
|
+
*/
|
|
123
|
+
export function createRealSigningFunction(
|
|
124
|
+
crypto: NodeCryptoProvider,
|
|
125
|
+
identity: AgentIdentity
|
|
126
|
+
): VCSigningFunction {
|
|
127
|
+
return async (canonicalVC: string, issuerDid: string, kid: string): Promise<Proof> => {
|
|
128
|
+
const data = new TextEncoder().encode(canonicalVC);
|
|
129
|
+
const signature = await crypto.sign(data, identity.privateKey);
|
|
130
|
+
const proofValue = Buffer.from(signature).toString('base64url');
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
type: 'Ed25519Signature2020',
|
|
134
|
+
created: new Date().toISOString(),
|
|
135
|
+
verificationMethod: kid,
|
|
136
|
+
proofPurpose: 'assertionMethod',
|
|
137
|
+
proofValue,
|
|
138
|
+
};
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Creates a real SignatureVerificationFunction for the VC verifier.
|
|
144
|
+
* Strips `proof`, canonicalizes, and verifies the Ed25519 signature.
|
|
145
|
+
*/
|
|
146
|
+
export function createRealSignatureVerifier(
|
|
147
|
+
crypto: NodeCryptoProvider
|
|
148
|
+
): SignatureVerificationFunction {
|
|
149
|
+
return async (vc, publicKeyJwk) => {
|
|
150
|
+
try {
|
|
151
|
+
const jwk = publicKeyJwk as { x?: string };
|
|
152
|
+
if (!jwk.x) {
|
|
153
|
+
return { valid: false, reason: 'Missing public key x coordinate' };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Decode the public key from base64url (JWK x parameter)
|
|
157
|
+
const publicKeyBase64 = Buffer.from(jwk.x, 'base64url').toString('base64');
|
|
158
|
+
|
|
159
|
+
// Strip proof and canonicalize
|
|
160
|
+
const vcWithoutProof = { ...vc } as Record<string, unknown>;
|
|
161
|
+
delete vcWithoutProof['proof'];
|
|
162
|
+
const canonicalVC = canonicalizeJSON(vcWithoutProof);
|
|
163
|
+
const data = new TextEncoder().encode(canonicalVC);
|
|
164
|
+
|
|
165
|
+
// Extract signature from proof
|
|
166
|
+
const proofValue = vc.proof?.proofValue;
|
|
167
|
+
if (!proofValue) {
|
|
168
|
+
return { valid: false, reason: 'Missing proofValue' };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const signature = Buffer.from(proofValue as string, 'base64url');
|
|
172
|
+
const isValid = await crypto.verify(data, new Uint8Array(signature), publicKeyBase64);
|
|
173
|
+
|
|
174
|
+
return { valid: isValid, reason: isValid ? undefined : 'Signature verification failed' };
|
|
175
|
+
} catch (error) {
|
|
176
|
+
return {
|
|
177
|
+
valid: false,
|
|
178
|
+
reason: `Verification error: ${error instanceof Error ? error.message : String(error)}`,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── Compression (real gzip via Node.js zlib) ────────────────────
|
|
185
|
+
|
|
186
|
+
export const nodeCompressor: CompressionFunction = {
|
|
187
|
+
compress(data: Uint8Array): Promise<Uint8Array> {
|
|
188
|
+
return new Promise((resolve, reject) => {
|
|
189
|
+
zlib.gzip(Buffer.from(data), (err, result) => {
|
|
190
|
+
if (err) reject(err);
|
|
191
|
+
else resolve(new Uint8Array(result));
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
export const nodeDecompressor: DecompressionFunction = {
|
|
198
|
+
decompress(data: Uint8Array): Promise<Uint8Array> {
|
|
199
|
+
return new Promise((resolve, reject) => {
|
|
200
|
+
zlib.gunzip(Buffer.from(data), (err, result) => {
|
|
201
|
+
if (err) reject(err);
|
|
202
|
+
else resolve(new Uint8Array(result));
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
// ── StatusList2021 Manager (real) ───────────────────────────────
|
|
209
|
+
|
|
210
|
+
export interface RealStatusListSetup {
|
|
211
|
+
manager: StatusList2021Manager;
|
|
212
|
+
storage: MemoryStatusListStorage;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function createRealStatusListManager(
|
|
216
|
+
crypto: NodeCryptoProvider,
|
|
217
|
+
identity: AgentIdentity,
|
|
218
|
+
options?: { statusListBaseUrl?: string; defaultListSize?: number }
|
|
219
|
+
): RealStatusListSetup {
|
|
220
|
+
const storage = new MemoryStatusListStorage();
|
|
221
|
+
const signingFunction = createRealSigningFunction(crypto, identity);
|
|
222
|
+
|
|
223
|
+
const identityProvider = {
|
|
224
|
+
getDid: () => identity.did,
|
|
225
|
+
getKeyId: () => identity.kid,
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const manager = new StatusList2021Manager(
|
|
229
|
+
storage,
|
|
230
|
+
identityProvider,
|
|
231
|
+
signingFunction,
|
|
232
|
+
nodeCompressor,
|
|
233
|
+
nodeDecompressor,
|
|
234
|
+
options
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
return { manager, storage };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── Re-exports for convenience ──────────────────────────────────
|
|
241
|
+
|
|
242
|
+
export { MemoryNonceCacheProvider } from '../../../providers/memory.js';
|
|
243
|
+
export { MemoryIdentityProvider } from '../../../providers/memory.js';
|
|
244
|
+
export { MemoryDelegationGraphStorage } from '../../../delegation/storage/memory-graph-storage.js';
|
|
245
|
+
export { MemoryStatusListStorage } from '../../../delegation/storage/memory-statuslist-storage.js';
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proof Boundary Audit Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests the proof generation → verification pipeline at exact boundary
|
|
5
|
+
* conditions: timestamp skew thresholds, nonce scoping, malformed proofs.
|
|
6
|
+
* Uses ControllableClockProvider for deterministic timestamp testing.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
|
|
10
|
+
import { ProofGenerator } from '../../proof/generator.js';
|
|
11
|
+
import { ProofVerifier } from '../../proof/verifier.js';
|
|
12
|
+
import type { AgentIdentity } from '../../providers/base.js';
|
|
13
|
+
import type { DetachedProof } from '../../types/protocol.js';
|
|
14
|
+
import { extractPublicKeyFromDidKey, publicKeyToJwk } from '../../delegation/did-key-resolver.js';
|
|
15
|
+
import {
|
|
16
|
+
createRealCryptoProvider,
|
|
17
|
+
createRealIdentity,
|
|
18
|
+
ControllableClockProvider,
|
|
19
|
+
RealFetchProvider,
|
|
20
|
+
MemoryNonceCacheProvider,
|
|
21
|
+
} from './helpers/crypto-helpers.js';
|
|
22
|
+
import { NodeCryptoProvider } from '../utils/node-crypto-provider.js';
|
|
23
|
+
import type { Ed25519JWK } from '../../utils/crypto-service.js';
|
|
24
|
+
|
|
25
|
+
describe('Proof Boundary Audit', () => {
|
|
26
|
+
let crypto: NodeCryptoProvider;
|
|
27
|
+
let agentA: AgentIdentity;
|
|
28
|
+
let agentB: AgentIdentity;
|
|
29
|
+
const SKEW_SECONDS = 300; // default 5 minutes
|
|
30
|
+
|
|
31
|
+
beforeAll(async () => {
|
|
32
|
+
crypto = createRealCryptoProvider();
|
|
33
|
+
agentA = await createRealIdentity(crypto);
|
|
34
|
+
agentB = await createRealIdentity(crypto);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
function makeGenerator(identity: AgentIdentity): ProofGenerator {
|
|
38
|
+
return new ProofGenerator(
|
|
39
|
+
{ did: identity.did, kid: identity.kid, privateKey: identity.privateKey, publicKey: identity.publicKey },
|
|
40
|
+
crypto
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function makeVerifier(clock: ControllableClockProvider): {
|
|
45
|
+
verifier: ProofVerifier;
|
|
46
|
+
nonceCache: MemoryNonceCacheProvider;
|
|
47
|
+
} {
|
|
48
|
+
const nonceCache = new MemoryNonceCacheProvider();
|
|
49
|
+
const verifier = new ProofVerifier({
|
|
50
|
+
cryptoProvider: crypto,
|
|
51
|
+
clockProvider: clock,
|
|
52
|
+
nonceCacheProvider: nonceCache,
|
|
53
|
+
fetchProvider: new RealFetchProvider(),
|
|
54
|
+
timestampSkewSeconds: SKEW_SECONDS,
|
|
55
|
+
});
|
|
56
|
+
return { verifier, nonceCache };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function getPublicKeyJwk(identity: AgentIdentity): Ed25519JWK {
|
|
60
|
+
const rawPublicKey = extractPublicKeyFromDidKey(identity.did);
|
|
61
|
+
const jwk = publicKeyToJwk(rawPublicKey!);
|
|
62
|
+
jwk.kid = identity.kid;
|
|
63
|
+
return jwk as Ed25519JWK;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function generateProof(identity: AgentIdentity): Promise<DetachedProof> {
|
|
67
|
+
const gen = makeGenerator(identity);
|
|
68
|
+
return gen.generateProof(
|
|
69
|
+
{ method: 'tools/call', params: { name: 'test-tool', arguments: { input: 'hello' } } },
|
|
70
|
+
{ data: { output: 'world' } },
|
|
71
|
+
{
|
|
72
|
+
sessionId: 'sess_test_boundary',
|
|
73
|
+
audience: 'did:web:test-server.example.com',
|
|
74
|
+
nonce: `nonce-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
75
|
+
timestamp: Math.floor(Date.now() / 1000),
|
|
76
|
+
createdAt: Math.floor(Date.now() / 1000),
|
|
77
|
+
lastActivity: Math.floor(Date.now() / 1000),
|
|
78
|
+
ttlMinutes: 30,
|
|
79
|
+
identityState: 'anonymous',
|
|
80
|
+
}
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Timestamp Skew Boundary Tests ─────────────────────────────
|
|
85
|
+
|
|
86
|
+
describe('timestamp skew boundaries', () => {
|
|
87
|
+
it('should accept proof at exactly the skew boundary (300s)', async () => {
|
|
88
|
+
// Generate proof first, then use its actual meta.ts to set the clock.
|
|
89
|
+
// This avoids a race where a second boundary is crossed between
|
|
90
|
+
// capturing the timestamp and generating the proof (which would
|
|
91
|
+
// cause a JWS signature mismatch).
|
|
92
|
+
const proof = await generateProof(agentA);
|
|
93
|
+
const proofTimestampMs = proof.meta.ts * 1000;
|
|
94
|
+
|
|
95
|
+
// Clock is exactly SKEW_SECONDS * 1000 ms ahead of the proof timestamp
|
|
96
|
+
const clock = new ControllableClockProvider(proofTimestampMs + SKEW_SECONDS * 1000);
|
|
97
|
+
const { verifier } = makeVerifier(clock);
|
|
98
|
+
|
|
99
|
+
const jwk = getPublicKeyJwk(agentA);
|
|
100
|
+
const result = await verifier.verifyProof(proof, jwk);
|
|
101
|
+
|
|
102
|
+
expect(result.valid).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should reject proof 1ms beyond the skew boundary', async () => {
|
|
106
|
+
const proof = await generateProof(agentA);
|
|
107
|
+
const proofTimestampMs = proof.meta.ts * 1000;
|
|
108
|
+
|
|
109
|
+
// Clock is 1ms beyond the skew window
|
|
110
|
+
const clock = new ControllableClockProvider(proofTimestampMs + SKEW_SECONDS * 1000 + 1);
|
|
111
|
+
const { verifier } = makeVerifier(clock);
|
|
112
|
+
|
|
113
|
+
const jwk = getPublicKeyJwk(agentA);
|
|
114
|
+
const result = await verifier.verifyProof(proof, jwk);
|
|
115
|
+
|
|
116
|
+
expect(result.valid).toBe(false);
|
|
117
|
+
expect(result.reason).toContain('skew');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should accept proof when verifier clock is behind by exactly the skew boundary', async () => {
|
|
121
|
+
// Generate proof with real timestamp (meta.ts ≈ now)
|
|
122
|
+
const proof = await generateProof(agentA);
|
|
123
|
+
const proofTimestampMs = proof.meta.ts * 1000;
|
|
124
|
+
|
|
125
|
+
// Set verifier clock to be exactly SKEW_SECONDS behind the proof
|
|
126
|
+
// This simulates verifying a proof from a clock-ahead agent
|
|
127
|
+
const clock = new ControllableClockProvider(proofTimestampMs - SKEW_SECONDS * 1000);
|
|
128
|
+
const { verifier } = makeVerifier(clock);
|
|
129
|
+
|
|
130
|
+
const jwk = getPublicKeyJwk(agentA);
|
|
131
|
+
const result = await verifier.verifyProof(proof, jwk);
|
|
132
|
+
|
|
133
|
+
expect(result.valid).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should reject proof when verifier clock is behind beyond the skew boundary', async () => {
|
|
137
|
+
const proof = await generateProof(agentA);
|
|
138
|
+
const proofTimestampMs = proof.meta.ts * 1000;
|
|
139
|
+
|
|
140
|
+
// Set verifier clock 1ms further than the boundary
|
|
141
|
+
const clock = new ControllableClockProvider(proofTimestampMs - SKEW_SECONDS * 1000 - 1);
|
|
142
|
+
const { verifier } = makeVerifier(clock);
|
|
143
|
+
|
|
144
|
+
const jwk = getPublicKeyJwk(agentA);
|
|
145
|
+
const result = await verifier.verifyProof(proof, jwk);
|
|
146
|
+
|
|
147
|
+
expect(result.valid).toBe(false);
|
|
148
|
+
expect(result.reason).toContain('skew');
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// ── Nonce Scoping Tests ───────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
describe('nonce scoping', () => {
|
|
155
|
+
it('should allow same nonce value from different agents (no cross-agent collision)', async () => {
|
|
156
|
+
const sharedNonce = 'shared-nonce-value-12345';
|
|
157
|
+
const clock = new ControllableClockProvider(Date.now());
|
|
158
|
+
const { verifier } = makeVerifier(clock);
|
|
159
|
+
|
|
160
|
+
// Generate proofs for both agents with the same nonce
|
|
161
|
+
const genA = makeGenerator(agentA);
|
|
162
|
+
const genB = makeGenerator(agentB);
|
|
163
|
+
|
|
164
|
+
const session = {
|
|
165
|
+
sessionId: 'sess_nonce_test',
|
|
166
|
+
audience: 'did:web:server.example.com',
|
|
167
|
+
nonce: sharedNonce,
|
|
168
|
+
timestamp: Math.floor(Date.now() / 1000),
|
|
169
|
+
createdAt: Math.floor(Date.now() / 1000),
|
|
170
|
+
lastActivity: Math.floor(Date.now() / 1000),
|
|
171
|
+
ttlMinutes: 30,
|
|
172
|
+
identityState: 'anonymous' as const,
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const request = { method: 'tools/call', params: { name: 'test' } };
|
|
176
|
+
const response = { data: { result: 'ok' } };
|
|
177
|
+
|
|
178
|
+
const proofA = await genA.generateProof(request, response, session);
|
|
179
|
+
const proofB = await genB.generateProof(request, response, session);
|
|
180
|
+
|
|
181
|
+
const jwkA = getPublicKeyJwk(agentA);
|
|
182
|
+
const jwkB = getPublicKeyJwk(agentB);
|
|
183
|
+
|
|
184
|
+
const resultA = await verifier.verifyProof(proofA, jwkA);
|
|
185
|
+
expect(resultA.valid).toBe(true);
|
|
186
|
+
|
|
187
|
+
// Same nonce, different agent — should also succeed
|
|
188
|
+
const resultB = await verifier.verifyProof(proofB, jwkB);
|
|
189
|
+
expect(resultB.valid).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('should reject replay of same nonce from same agent', async () => {
|
|
193
|
+
const clock = new ControllableClockProvider(Date.now());
|
|
194
|
+
const { verifier } = makeVerifier(clock);
|
|
195
|
+
|
|
196
|
+
const proof = await generateProof(agentA);
|
|
197
|
+
const jwk = getPublicKeyJwk(agentA);
|
|
198
|
+
|
|
199
|
+
const result1 = await verifier.verifyProof(proof, jwk);
|
|
200
|
+
expect(result1.valid).toBe(true);
|
|
201
|
+
|
|
202
|
+
// Same exact proof (same nonce, same agent) — should be rejected as replay
|
|
203
|
+
const result2 = await verifier.verifyProof(proof, jwk);
|
|
204
|
+
expect(result2.valid).toBe(false);
|
|
205
|
+
expect(result2.reason).toContain('replay');
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// ── Wrong Key Tests ───────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
describe('signature verification with wrong key', () => {
|
|
212
|
+
it('should reject proof verified with wrong public key', async () => {
|
|
213
|
+
const clock = new ControllableClockProvider(Date.now());
|
|
214
|
+
const { verifier } = makeVerifier(clock);
|
|
215
|
+
|
|
216
|
+
// Generate proof with agent A
|
|
217
|
+
const proof = await generateProof(agentA);
|
|
218
|
+
|
|
219
|
+
// Try to verify with agent B's public key
|
|
220
|
+
const wrongJwk = getPublicKeyJwk(agentB);
|
|
221
|
+
|
|
222
|
+
const result = await verifier.verifyProof(proof, wrongJwk);
|
|
223
|
+
expect(result.valid).toBe(false);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// ── Malformed Proof Tests ─────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
describe('malformed proof rejection', () => {
|
|
230
|
+
it('should reject proof with empty nonce', async () => {
|
|
231
|
+
const clock = new ControllableClockProvider(Date.now());
|
|
232
|
+
const { verifier } = makeVerifier(clock);
|
|
233
|
+
|
|
234
|
+
const proof = await generateProof(agentA);
|
|
235
|
+
proof.meta.nonce = '';
|
|
236
|
+
|
|
237
|
+
const jwk = getPublicKeyJwk(agentA);
|
|
238
|
+
const result = await verifier.verifyProof(proof, jwk);
|
|
239
|
+
|
|
240
|
+
expect(result.valid).toBe(false);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('should reject proof with malformed requestHash', async () => {
|
|
244
|
+
const clock = new ControllableClockProvider(Date.now());
|
|
245
|
+
const { verifier } = makeVerifier(clock);
|
|
246
|
+
|
|
247
|
+
const proof = await generateProof(agentA);
|
|
248
|
+
proof.meta.requestHash = 'md5:abc123';
|
|
249
|
+
|
|
250
|
+
const jwk = getPublicKeyJwk(agentA);
|
|
251
|
+
const result = await verifier.verifyProof(proof, jwk);
|
|
252
|
+
|
|
253
|
+
expect(result.valid).toBe(false);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('should reject proof with ts=0', async () => {
|
|
257
|
+
const clock = new ControllableClockProvider(Date.now());
|
|
258
|
+
const { verifier } = makeVerifier(clock);
|
|
259
|
+
|
|
260
|
+
const proof = await generateProof(agentA);
|
|
261
|
+
proof.meta.ts = 0;
|
|
262
|
+
|
|
263
|
+
const jwk = getPublicKeyJwk(agentA);
|
|
264
|
+
const result = await verifier.verifyProof(proof, jwk);
|
|
265
|
+
|
|
266
|
+
expect(result.valid).toBe(false);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StatusList2021 + Bitstring Round-Trip Audit Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests the full lifecycle: allocate status entry → check (not revoked) →
|
|
5
|
+
* revoke → check (revoked), using real gzip compression and bitstring encoding.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, beforeEach, beforeAll } from 'vitest';
|
|
9
|
+
import type { AgentIdentity } from '../../providers/base.js';
|
|
10
|
+
import {
|
|
11
|
+
createRealCryptoProvider,
|
|
12
|
+
createRealIdentity,
|
|
13
|
+
createRealStatusListManager,
|
|
14
|
+
type RealStatusListSetup,
|
|
15
|
+
} from './helpers/crypto-helpers.js';
|
|
16
|
+
import { NodeCryptoProvider } from '../utils/node-crypto-provider.js';
|
|
17
|
+
|
|
18
|
+
describe('StatusList Bitstring Round-Trip Audit', () => {
|
|
19
|
+
let crypto: NodeCryptoProvider;
|
|
20
|
+
let identity: AgentIdentity;
|
|
21
|
+
let setup: RealStatusListSetup;
|
|
22
|
+
|
|
23
|
+
beforeAll(async () => {
|
|
24
|
+
crypto = createRealCryptoProvider();
|
|
25
|
+
identity = await createRealIdentity(crypto);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
setup = createRealStatusListManager(crypto, identity);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should allocate, check not revoked, revoke, then check revoked', async () => {
|
|
33
|
+
const statusEntry = await setup.manager.allocateStatusEntry('revocation');
|
|
34
|
+
|
|
35
|
+
// Initially not revoked
|
|
36
|
+
const beforeRevoke = await setup.manager.checkStatus(statusEntry);
|
|
37
|
+
expect(beforeRevoke).toBe(false);
|
|
38
|
+
|
|
39
|
+
// Revoke
|
|
40
|
+
await setup.manager.updateStatus(statusEntry, true);
|
|
41
|
+
|
|
42
|
+
// Now revoked
|
|
43
|
+
const afterRevoke = await setup.manager.checkStatus(statusEntry);
|
|
44
|
+
expect(afterRevoke).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should selectively revoke entries in the same status list', async () => {
|
|
48
|
+
// Allocate 5 entries
|
|
49
|
+
const entries = [];
|
|
50
|
+
for (let i = 0; i < 5; i++) {
|
|
51
|
+
entries.push(await setup.manager.allocateStatusEntry('revocation'));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Revoke entries at index 1 and 3
|
|
55
|
+
await setup.manager.updateStatus(entries[1]!, true);
|
|
56
|
+
await setup.manager.updateStatus(entries[3]!, true);
|
|
57
|
+
|
|
58
|
+
// Check all 5
|
|
59
|
+
const statuses = await Promise.all(
|
|
60
|
+
entries.map((e) => setup.manager.checkStatus(e))
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
expect(statuses[0]).toBe(false);
|
|
64
|
+
expect(statuses[1]).toBe(true);
|
|
65
|
+
expect(statuses[2]).toBe(false);
|
|
66
|
+
expect(statuses[3]).toBe(true);
|
|
67
|
+
expect(statuses[4]).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should report revoked indices correctly via getRevokedIndices', async () => {
|
|
71
|
+
const entries = [];
|
|
72
|
+
for (let i = 0; i < 6; i++) {
|
|
73
|
+
entries.push(await setup.manager.allocateStatusEntry('revocation'));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Revoke indices 0, 2, 5
|
|
77
|
+
await setup.manager.updateStatus(entries[0]!, true);
|
|
78
|
+
await setup.manager.updateStatus(entries[2]!, true);
|
|
79
|
+
await setup.manager.updateStatus(entries[5]!, true);
|
|
80
|
+
|
|
81
|
+
const statusListId = `${setup.manager.getStatusListBaseUrl()}/revocation/v1`;
|
|
82
|
+
const revoked = await setup.manager.getRevokedIndices(statusListId);
|
|
83
|
+
|
|
84
|
+
expect(revoked).toContain(0);
|
|
85
|
+
expect(revoked).toContain(2);
|
|
86
|
+
expect(revoked).toContain(5);
|
|
87
|
+
expect(revoked).not.toContain(1);
|
|
88
|
+
expect(revoked).not.toContain(3);
|
|
89
|
+
expect(revoked).not.toContain(4);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should re-sign the status list credential after update', async () => {
|
|
93
|
+
const entry = await setup.manager.allocateStatusEntry('revocation');
|
|
94
|
+
|
|
95
|
+
const statusListId = `${setup.manager.getStatusListBaseUrl()}/revocation/v1`;
|
|
96
|
+
const credBefore = await setup.storage.getStatusList(statusListId);
|
|
97
|
+
const proofBefore = credBefore?.proof?.proofValue;
|
|
98
|
+
|
|
99
|
+
// Revoke — triggers re-signing
|
|
100
|
+
await setup.manager.updateStatus(entry, true);
|
|
101
|
+
|
|
102
|
+
const credAfter = await setup.storage.getStatusList(statusListId);
|
|
103
|
+
const proofAfter = credAfter?.proof?.proofValue;
|
|
104
|
+
|
|
105
|
+
expect(proofBefore).toBeDefined();
|
|
106
|
+
expect(proofAfter).toBeDefined();
|
|
107
|
+
expect(proofAfter).not.toBe(proofBefore);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should handle bits at byte boundaries correctly (index 7 and 8)', async () => {
|
|
111
|
+
// Allocate enough entries to reach index 7 and 8
|
|
112
|
+
const entries = [];
|
|
113
|
+
for (let i = 0; i < 9; i++) {
|
|
114
|
+
entries.push(await setup.manager.allocateStatusEntry('revocation'));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Index 7 = last bit of byte 0, Index 8 = first bit of byte 1
|
|
118
|
+
await setup.manager.updateStatus(entries[7]!, true);
|
|
119
|
+
await setup.manager.updateStatus(entries[8]!, true);
|
|
120
|
+
|
|
121
|
+
expect(await setup.manager.checkStatus(entries[6]!)).toBe(false);
|
|
122
|
+
expect(await setup.manager.checkStatus(entries[7]!)).toBe(true);
|
|
123
|
+
expect(await setup.manager.checkStatus(entries[8]!)).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should un-revoke (restore) an entry by setting status to false', async () => {
|
|
127
|
+
const entry = await setup.manager.allocateStatusEntry('revocation');
|
|
128
|
+
|
|
129
|
+
await setup.manager.updateStatus(entry, true);
|
|
130
|
+
expect(await setup.manager.checkStatus(entry)).toBe(true);
|
|
131
|
+
|
|
132
|
+
await setup.manager.updateStatus(entry, false);
|
|
133
|
+
expect(await setup.manager.checkStatus(entry)).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
});
|