@mcpose/audit 2.0.3 → 3.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/LICENSE +21 -0
- package/README.md +440 -28
- package/dist/chain.d.ts +49 -6
- package/dist/chain.d.ts.map +1 -1
- package/dist/chain.js +227 -28
- package/dist/chain.js.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/middleware.d.ts +3 -1
- package/dist/middleware.d.ts.map +1 -1
- package/dist/middleware.js +499 -85
- package/dist/middleware.js.map +1 -1
- package/dist/sensitivity.d.ts +11 -2
- package/dist/sensitivity.d.ts.map +1 -1
- package/dist/sensitivity.js +18 -2
- package/dist/sensitivity.js.map +1 -1
- package/dist/signingKey.d.ts +8 -0
- package/dist/signingKey.d.ts.map +1 -1
- package/dist/signingKey.js +21 -4
- package/dist/signingKey.js.map +1 -1
- package/dist/subjectKeyStore.d.ts +17 -0
- package/dist/subjectKeyStore.d.ts.map +1 -0
- package/dist/subjectKeyStore.js +37 -0
- package/dist/subjectKeyStore.js.map +1 -0
- package/dist/types.d.ts +170 -18
- package/dist/types.d.ts.map +1 -1
- package/dist/verify.d.ts +24 -0
- package/dist/verify.d.ts.map +1 -0
- package/dist/verify.js +66 -0
- package/dist/verify.js.map +1 -0
- package/package.json +43 -15
- package/src/__tests__/chain.batch.test.ts +30 -0
- package/src/__tests__/chain.test.ts +433 -0
- package/src/__tests__/erasable.test.ts +499 -0
- package/src/__tests__/merkle-batch.middleware.test.ts +96 -0
- package/src/__tests__/middleware.http.test.ts +172 -0
- package/src/__tests__/middleware.rejection.test.ts +183 -0
- package/src/__tests__/middleware.robustness.test.ts +809 -0
- package/src/__tests__/middleware.test.ts +1121 -0
- package/src/__tests__/middleware.unusableResult.test.ts +141 -0
- package/src/__tests__/properties.test.ts +764 -0
- package/src/__tests__/sensitivity.test.ts +72 -0
- package/src/__tests__/signingKey.test.ts +76 -0
- package/src/chain.ts +317 -0
- package/src/index.ts +31 -0
- package/src/middleware.ts +781 -0
- package/src/sensitivity.ts +33 -0
- package/src/signingKey.ts +35 -0
- package/src/subjectKeyStore.ts +38 -0
- package/src/types.ts +287 -0
- package/src/verify.ts +92 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
SensitivityTier,
|
|
3
|
+
SensitivityResolverFn,
|
|
4
|
+
SensitivityOverrideFn,
|
|
5
|
+
} from './types.js';
|
|
6
|
+
|
|
7
|
+
const TIERS: ReadonlySet<string> = new Set(['low', 'medium', 'high']);
|
|
8
|
+
|
|
9
|
+
function validTier(value: unknown): SensitivityTier {
|
|
10
|
+
// Fail CLOSED: anything that is not a known tier resolves to 'high'
|
|
11
|
+
// (encrypt). Guards against typos in the map and misbehaving overrides.
|
|
12
|
+
return TIERS.has(value as string) ? (value as SensitivityTier) : 'high';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Builds a resolver from a static tool→tier map, with an optional dynamic
|
|
17
|
+
* override that receives the map's resolution as its fourth argument.
|
|
18
|
+
*
|
|
19
|
+
* Unknown tools resolve to `'high'` (encrypt by default). The lookup uses
|
|
20
|
+
* `Object.hasOwn`, so prototype-inherited keys (a tool named `toString` or
|
|
21
|
+
* `constructor` — tool names are attacker-controlled) cannot bypass that
|
|
22
|
+
* default.
|
|
23
|
+
*/
|
|
24
|
+
export function createSensitivityResolver(
|
|
25
|
+
map: Record<string, SensitivityTier>,
|
|
26
|
+
override?: SensitivityOverrideFn,
|
|
27
|
+
): SensitivityResolverFn {
|
|
28
|
+
return (tool, identity, args) => {
|
|
29
|
+
const mapTier = validTier(Object.hasOwn(map, tool) ? map[tool] : undefined);
|
|
30
|
+
if (override) return validTier(override(tool, identity, args, mapTier));
|
|
31
|
+
return mapTier;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { createHmac } from 'node:crypto';
|
|
2
|
+
import type { SigningKeyProvider } from './types.js';
|
|
3
|
+
|
|
4
|
+
// keyId derivation label. v1 used bare SHA256(secret), which let anyone
|
|
5
|
+
// holding a manifest brute-force a low-entropy secret offline from the
|
|
6
|
+
// published keyId. HMAC with a domain label is not invertible into a
|
|
7
|
+
// dictionary check any faster than attacking the signature itself, and
|
|
8
|
+
// keeps keyId stable per secret. See ADR-0004.
|
|
9
|
+
const DOMAIN_KEYID = 'mcpose/v2/keyid';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* In-process HMAC-SHA256 signing provider.
|
|
13
|
+
*
|
|
14
|
+
* The secret must be high-entropy (>= 32 random bytes). `keyId` is PUBLIC —
|
|
15
|
+
* it is published in every ReplayManifest — so it must never be used as key
|
|
16
|
+
* material (ADR-0003), and a guessable secret is offline-attackable from
|
|
17
|
+
* any published manifest.
|
|
18
|
+
*/
|
|
19
|
+
export function createDefaultSigningKeyProvider(
|
|
20
|
+
secret: Buffer | string,
|
|
21
|
+
): SigningKeyProvider {
|
|
22
|
+
// The secret is handed to createHmac as given. Normalizing a string to a
|
|
23
|
+
// Buffer first was a no-op: createHmac encodes a string key as utf8, byte
|
|
24
|
+
// for byte what Buffer.from produces, so no input could tell the two
|
|
25
|
+
// apart. The equivalence is pinned by a test (#110).
|
|
26
|
+
const keyId = createHmac('sha256', secret).update(DOMAIN_KEYID).digest('hex');
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
algorithm: 'HMAC-SHA256',
|
|
30
|
+
keyId,
|
|
31
|
+
async sign(data: Buffer): Promise<Buffer> {
|
|
32
|
+
return createHmac('sha256', secret).update(data).digest();
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import type { SubjectKeyStore } from './types.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Reference {@link SubjectKeyStore}, backed by a `Map` in this process.
|
|
6
|
+
*
|
|
7
|
+
* NOT DURABLE, and not for production. The keys live only in this process's
|
|
8
|
+
* heap, so a restart erases every subject at once and a second proxy instance
|
|
9
|
+
* cannot decrypt what the first recorded. Both are silent failures: the chain
|
|
10
|
+
* still verifies, and the loss shows up only when someone tries to read a
|
|
11
|
+
* payload back.
|
|
12
|
+
*
|
|
13
|
+
* A real deployment needs a durable, access-controlled store — a KMS, an HSM,
|
|
14
|
+
* or an encrypted table whose read path is audited — because in erasable mode
|
|
15
|
+
* this store IS the confidentiality of every high-tier payload it holds a key
|
|
16
|
+
* for (ADR-0018).
|
|
17
|
+
*/
|
|
18
|
+
export function createInMemorySubjectKeyStore(): SubjectKeyStore {
|
|
19
|
+
const keys = new Map<string, Buffer>();
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
async getOrCreate(subjectId: string): Promise<Buffer> {
|
|
23
|
+
const existing = keys.get(subjectId);
|
|
24
|
+
if (existing !== undefined) return existing;
|
|
25
|
+
// 256-bit, random rather than derived: a key recomputable from the
|
|
26
|
+
// signing secret could not be destroyed, which is the whole point.
|
|
27
|
+
const created = randomBytes(32);
|
|
28
|
+
keys.set(subjectId, created);
|
|
29
|
+
return created;
|
|
30
|
+
},
|
|
31
|
+
async destroy(subjectId: string) {
|
|
32
|
+
keys.delete(subjectId);
|
|
33
|
+
// Idempotent: an unknown subject still gets a tombstone, so a repeated
|
|
34
|
+
// erasure request produces evidence rather than an error.
|
|
35
|
+
return { destroyedAt: new Date().toISOString() };
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Identity,
|
|
3
|
+
PromptMiddleware,
|
|
4
|
+
ProxyIdentity,
|
|
5
|
+
RejectionReason,
|
|
6
|
+
ToolMiddleware,
|
|
7
|
+
} from 'mcpose';
|
|
8
|
+
|
|
9
|
+
// ── Sensitivity ────────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
export type SensitivityTier = 'low' | 'medium' | 'high';
|
|
12
|
+
|
|
13
|
+
export type SensitivityResolverFn = (
|
|
14
|
+
tool: string,
|
|
15
|
+
identity: Identity,
|
|
16
|
+
args: Record<string, unknown>,
|
|
17
|
+
) => SensitivityTier;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Override passed to `createSensitivityResolver`. Receives the static map's
|
|
21
|
+
* resolution as `mapTier` (already defaulted to `'high'` for unknown tools)
|
|
22
|
+
* so it can fall back: `(tool, id, args, mapTier) => mapTier`.
|
|
23
|
+
*/
|
|
24
|
+
export type SensitivityOverrideFn = (
|
|
25
|
+
tool: string,
|
|
26
|
+
identity: Identity,
|
|
27
|
+
args: Record<string, unknown>,
|
|
28
|
+
mapTier: SensitivityTier,
|
|
29
|
+
) => SensitivityTier;
|
|
30
|
+
|
|
31
|
+
// ── Signing ────────────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
export interface SigningKeyProvider {
|
|
34
|
+
sign(data: Buffer): Promise<Buffer>;
|
|
35
|
+
keyId: string;
|
|
36
|
+
algorithm: 'HMAC-SHA256';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── Cryptographic erasure ──────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
/** Evidence that a subject's key was destroyed, returned by `destroy`. */
|
|
42
|
+
export interface SubjectKeyTombstone {
|
|
43
|
+
/** ISO timestamp of the destruction. */
|
|
44
|
+
destroyedAt: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Custody of the per-subject keys that make erasable mode erasable (ADR-0018).
|
|
49
|
+
*
|
|
50
|
+
* Supplying one to `AuditOptions.keyStore` switches the audit layer into
|
|
51
|
+
* erasable mode: per-event encryption keys and the keyed payload hashes then
|
|
52
|
+
* derive from the stored subject key rather than from the signing secret, so
|
|
53
|
+
* `destroy(subjectId)` makes that subject's recorded payloads permanently
|
|
54
|
+
* unreadable and unconfirmable. The chain and the manifest are untouched.
|
|
55
|
+
*
|
|
56
|
+
* These keys are private key material with the same handling rules as the
|
|
57
|
+
* signing secret: they must never be logged, exported, or written into an
|
|
58
|
+
* event, a manifest, or telemetry. An implementation that stores them in the
|
|
59
|
+
* clear voids the encryption guarantee for every subject in it.
|
|
60
|
+
*/
|
|
61
|
+
export interface SubjectKeyStore {
|
|
62
|
+
/**
|
|
63
|
+
* The subject's 256-bit key, created at random on first use. Called before
|
|
64
|
+
* every audited call in erasable mode, so it must not be memoized in a way
|
|
65
|
+
* that outlives `destroy`: a subject that calls again after erasure gets a
|
|
66
|
+
* fresh key, and the old events stay dead.
|
|
67
|
+
*
|
|
68
|
+
* A rejection fails the audited call (the pre-call failure stage) rather
|
|
69
|
+
* than letting it run unaudited.
|
|
70
|
+
*/
|
|
71
|
+
getOrCreate(subjectId: string): Promise<Buffer>;
|
|
72
|
+
/**
|
|
73
|
+
* Permanently removes the subject's key and returns the tombstone that
|
|
74
|
+
* evidences the erasure. Idempotent: destroying an unknown subject still
|
|
75
|
+
* returns a tombstone, because "this subject holds no key" is the state the
|
|
76
|
+
* caller asked for either way.
|
|
77
|
+
*/
|
|
78
|
+
destroy(subjectId: string): Promise<SubjectKeyTombstone>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── Audit events ───────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
export interface AuditEventBase {
|
|
84
|
+
/** Equals the ProxyContext requestId. */
|
|
85
|
+
id: string;
|
|
86
|
+
/** ISO timestamp captured before the upstream call started. */
|
|
87
|
+
startedAt: string;
|
|
88
|
+
/** ISO timestamp captured after the upstream call settled. */
|
|
89
|
+
endedAt: string;
|
|
90
|
+
sessionId?: string;
|
|
91
|
+
identity: Identity;
|
|
92
|
+
delegatedFrom?: Identity[];
|
|
93
|
+
/**
|
|
94
|
+
* The proxy instance that recorded this event, from `ProxyContext.proxy`.
|
|
95
|
+
* Provenance, not a principal — never part of `delegatedFrom` (ADR-0012).
|
|
96
|
+
*
|
|
97
|
+
* Required, and covered by the chain unconditionally (ADR-0019). While it
|
|
98
|
+
* was omitted when absent, an event with no `proxy` and an event whose
|
|
99
|
+
* `proxy` was stripped produced the same preimage, so deleting recorded
|
|
100
|
+
* provenance was not chain-detectable.
|
|
101
|
+
*/
|
|
102
|
+
proxy: ProxyIdentity;
|
|
103
|
+
/**
|
|
104
|
+
* Present ONLY on events recorded for a prompt call (`prompts/get`), where
|
|
105
|
+
* `tool` holds the prompt name. An absent `kind` means a tool call, so
|
|
106
|
+
* every event recorded before prompts were audited keeps its meaning and
|
|
107
|
+
* its chain preimage (additive within v2, ADR-0012 and ADR-0014).
|
|
108
|
+
*/
|
|
109
|
+
kind?: 'prompt';
|
|
110
|
+
/**
|
|
111
|
+
* Present ONLY on events recorded in erasable mode, where the per-event
|
|
112
|
+
* encryption key and the keyed `inputHash`/`outputHash` derive from a
|
|
113
|
+
* destroyable subject key instead of the signing secret (ADR-0018).
|
|
114
|
+
*
|
|
115
|
+
* Optional and covered by the chain under the ADR-0012 omission rule,
|
|
116
|
+
* exactly as `kind` is: an absent marker means default mode with plain
|
|
117
|
+
* `sha256` hashes, so events recorded before erasable mode existed keep
|
|
118
|
+
* their preimage, and a stored event cannot be silently reinterpreted
|
|
119
|
+
* under the wrong hash scheme.
|
|
120
|
+
*/
|
|
121
|
+
erasable?: true;
|
|
122
|
+
tool: string;
|
|
123
|
+
duration_ms: number;
|
|
124
|
+
outcome: 'success' | 'rejected' | 'error';
|
|
125
|
+
/** Present when outcome is 'rejected' (from the MCP error's data field). */
|
|
126
|
+
rejectionReason?: RejectionReason;
|
|
127
|
+
/**
|
|
128
|
+
* Present when outcome is 'error': what the upstream call threw, or a fixed
|
|
129
|
+
* message when the call failed without throwing -- `{ name: 'ToolError' }`
|
|
130
|
+
* for a tool result that carried `isError: true` in-band, and
|
|
131
|
+
* `{ name: 'InvalidToolResult' }` when the call resolved to a value that is
|
|
132
|
+
* not the shape its `kind` expects.
|
|
133
|
+
*/
|
|
134
|
+
error?: { name: string; message: string };
|
|
135
|
+
inputHash: string;
|
|
136
|
+
outputHash: string;
|
|
137
|
+
chainHash: string;
|
|
138
|
+
replayManifestPosition: number;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export type LowAuditEvent = AuditEventBase & {
|
|
142
|
+
sensitivityTier: 'low';
|
|
143
|
+
inputRaw: Record<string, unknown>;
|
|
144
|
+
outputRaw: unknown;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
export type MediumAuditEvent = AuditEventBase & {
|
|
148
|
+
sensitivityTier: 'medium';
|
|
149
|
+
inputRaw: Record<string, unknown>;
|
|
150
|
+
outputRaw: unknown;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
export type HighAuditEvent = AuditEventBase & {
|
|
154
|
+
sensitivityTier: 'high';
|
|
155
|
+
inputEncrypted: string;
|
|
156
|
+
outputEncrypted: string;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
export type AuditEvent = LowAuditEvent | MediumAuditEvent | HighAuditEvent;
|
|
160
|
+
|
|
161
|
+
// ── Merkle + ReplayManifest ────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
export interface MerkleProof {
|
|
164
|
+
index: number;
|
|
165
|
+
siblings: string[];
|
|
166
|
+
directions: ('left' | 'right')[];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface ReplayManifest {
|
|
170
|
+
sessionId: string;
|
|
171
|
+
identity: Identity;
|
|
172
|
+
/**
|
|
173
|
+
* The proxy instance that produced this session's trail, captured when
|
|
174
|
+
* the session was first seen. Covered by the signature like every other
|
|
175
|
+
* field (ADR-0004, ADR-0012).
|
|
176
|
+
*
|
|
177
|
+
* Required, and included in the signing payload unconditionally
|
|
178
|
+
* (ADR-0019), so stripping it from a stored manifest fails the signature
|
|
179
|
+
* rather than rebuilding a payload that verifies.
|
|
180
|
+
*/
|
|
181
|
+
proxy: ProxyIdentity;
|
|
182
|
+
startedAt: string;
|
|
183
|
+
closedAt: string;
|
|
184
|
+
eventCount: number;
|
|
185
|
+
merkleRoot: string;
|
|
186
|
+
merkleProofs: MerkleProof[];
|
|
187
|
+
signedBy: string;
|
|
188
|
+
/**
|
|
189
|
+
* HMAC over the canonical serialization of the ENTIRE manifest (every
|
|
190
|
+
* field above, domain-separated) — not just the Merkle root. Verify with
|
|
191
|
+
* `verifyManifestSignature`, which accepts only this exact lowercase hex
|
|
192
|
+
* string: a respelling (uppercase, an appended character) does not verify.
|
|
193
|
+
*/
|
|
194
|
+
signature: string;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── Audit options + handle ─────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
export interface AuditOptions {
|
|
200
|
+
signingKey: SigningKeyProvider;
|
|
201
|
+
sensitivityResolver: SensitivityResolverFn;
|
|
202
|
+
/**
|
|
203
|
+
* Supply a store to run in ERASABLE mode (ADR-0018). Omit it and the audit
|
|
204
|
+
* layer behaves exactly as it always has, byte for byte: same preimages,
|
|
205
|
+
* same ciphertexts, same plain `sha256` payload hashes, no `erasable`
|
|
206
|
+
* marker on any event.
|
|
207
|
+
*
|
|
208
|
+
* With a store, the erasure unit is the data subject — the resolved
|
|
209
|
+
* `identity.sub` of each event, with anonymous events sharing the
|
|
210
|
+
* `anonymousIdentity()` bucket — and `destroy(sub)` renders that subject's
|
|
211
|
+
* recorded payloads permanently undecryptable and unconfirmable. Chain and
|
|
212
|
+
* manifest verification are unaffected, because payloads are bound to the
|
|
213
|
+
* chain only through their hashes.
|
|
214
|
+
*
|
|
215
|
+
* Key custody becomes your responsibility: see the README's erasable-mode
|
|
216
|
+
* section before choosing a store.
|
|
217
|
+
*/
|
|
218
|
+
keyStore?: SubjectKeyStore;
|
|
219
|
+
onEvent: (event: AuditEvent) => void | Promise<void>;
|
|
220
|
+
/**
|
|
221
|
+
* Called with the finished ReplayManifest when the host calls closeSession().
|
|
222
|
+
*
|
|
223
|
+
* Why this exists: ToolMiddleware is a pure per-request function with no
|
|
224
|
+
* lifecycle hooks. Sessions are owned by the HTTP transport, not by
|
|
225
|
+
* middleware. There is no in-band way for middleware to observe session close.
|
|
226
|
+
* This callback gives consumers a push-based way to receive the manifest
|
|
227
|
+
* exactly when the host signals the session has ended — mirroring how audit
|
|
228
|
+
* substrates in financial systems work: the host controls the flush boundary;
|
|
229
|
+
* the audit layer reacts.
|
|
230
|
+
*/
|
|
231
|
+
onManifest?: (manifest: ReplayManifest) => void | Promise<void>;
|
|
232
|
+
/**
|
|
233
|
+
* Record events for rejected calls (hidden tools etc.).
|
|
234
|
+
* @default true
|
|
235
|
+
*/
|
|
236
|
+
includeRejections?: boolean;
|
|
237
|
+
/**
|
|
238
|
+
* How long `closeSession` waits for calls already admitted to the session
|
|
239
|
+
* to append and persist their events before it seals the manifest. Past
|
|
240
|
+
* the deadline the manifest covers what has landed, and each straggler is
|
|
241
|
+
* reported through `onAuditError` with its tool and requestId. `Infinity`
|
|
242
|
+
* waits without bound. Any other value (NaN, a negative number) is
|
|
243
|
+
* rejected at construction, because it would leave the drain with no
|
|
244
|
+
* deadline at all.
|
|
245
|
+
* @default 30_000
|
|
246
|
+
*/
|
|
247
|
+
closeDrainTimeoutMs?: number;
|
|
248
|
+
/**
|
|
249
|
+
* Called when the audit layer itself fails (event serialization, a
|
|
250
|
+
* throwing onEvent sink). The audit layer NEVER throws into the tool-call
|
|
251
|
+
* path; failures are reported here instead. If this hook itself throws or
|
|
252
|
+
* returns a rejected promise, the failure is contained and never reaches
|
|
253
|
+
* the caller either.
|
|
254
|
+
* @default console.error
|
|
255
|
+
*/
|
|
256
|
+
onAuditError?: (
|
|
257
|
+
err: unknown,
|
|
258
|
+
info: { tool: string; requestId: string; sessionId?: string },
|
|
259
|
+
) => unknown;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export interface AuditMiddlewareHandle {
|
|
263
|
+
middleware: ToolMiddleware;
|
|
264
|
+
/**
|
|
265
|
+
* Audits `prompts/get` calls. Wire it into
|
|
266
|
+
* `ProxyOptions.promptMiddleware`; it shares the session chain with
|
|
267
|
+
* `middleware`, so tool and prompt events interleave in one trail.
|
|
268
|
+
* Prompt events carry `kind: 'prompt'` (ADR-0014).
|
|
269
|
+
*/
|
|
270
|
+
promptMiddleware: PromptMiddleware;
|
|
271
|
+
/**
|
|
272
|
+
* Signal that a session has ended. Waits for every call already admitted
|
|
273
|
+
* to the session to append and persist its event, then computes the
|
|
274
|
+
* Merkle tree over all audit events for the session, signs the full
|
|
275
|
+
* manifest, fires onManifest, and returns the ReplayManifest. A call still
|
|
276
|
+
* inside its handler at teardown therefore lands in the manifest rather
|
|
277
|
+
* than orphaned at position 0. Concurrent calls for the same session share
|
|
278
|
+
* one close. Returns undefined if the session had no events or is unknown.
|
|
279
|
+
*
|
|
280
|
+
* A rejection from the signing provider or from onManifest is retryable:
|
|
281
|
+
* the sealed session is retained, and calling closeSession again with the
|
|
282
|
+
* same id resumes where it stopped. A retry signs the same payload and
|
|
283
|
+
* delivers the same manifest, never a second signed artifact. The state
|
|
284
|
+
* is released only once onManifest has settled successfully.
|
|
285
|
+
*/
|
|
286
|
+
closeSession(sessionId: string): Promise<ReplayManifest | undefined>;
|
|
287
|
+
}
|
package/src/verify.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import type {
|
|
3
|
+
AuditEvent,
|
|
4
|
+
ReplayManifest,
|
|
5
|
+
SigningKeyProvider,
|
|
6
|
+
} from './types.js';
|
|
7
|
+
import { chainPreimageFields, computeChainHash } from './chain.js';
|
|
8
|
+
import { manifestSigningPayload } from './middleware.js';
|
|
9
|
+
|
|
10
|
+
const DOMAIN_CHAIN = Buffer.from('mcpose/v2/chain');
|
|
11
|
+
|
|
12
|
+
export type ChainVerification =
|
|
13
|
+
{ valid: true } | { valid: false; index: number; reason: string };
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Canonical hex: lowercase, even length, hex digits only, non-empty. This
|
|
17
|
+
* is what the producer writes (`digest('hex')`), and the exact string a
|
|
18
|
+
* third-party verifier compares.
|
|
19
|
+
*/
|
|
20
|
+
const CANONICAL_HEX = /^(?:[0-9a-f]{2})+$/;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Compares a stored hex string against the recomputed value in constant
|
|
24
|
+
* time. The stored string must be canonical first: Node's hex decoder drops
|
|
25
|
+
* a trailing odd nibble and stops at the first non-hex character, so
|
|
26
|
+
* `sig + '0'`, `sig + 'z'` and `SIG` all decode to the stored bytes and would
|
|
27
|
+
* otherwise verify. The verifiers authenticate the stored string itself, not
|
|
28
|
+
* just its decoding, so two implementations agree on one artifact (#281).
|
|
29
|
+
*/
|
|
30
|
+
function hexEqual(storedHex: string, expectedHex: string): boolean {
|
|
31
|
+
if (!CANONICAL_HEX.test(storedHex)) return false;
|
|
32
|
+
const a = Buffer.from(storedHex, 'hex');
|
|
33
|
+
const b = Buffer.from(expectedHex, 'hex');
|
|
34
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* KEYED chain verification: recomputes every event's chainHash from its
|
|
39
|
+
* preimage fields with the chain key derived through the signing oracle.
|
|
40
|
+
* Unlike the keyless `assertAuditChainIntegrity` in @mcpose/testing, this
|
|
41
|
+
* detects key-inconsistent forgeries — a rewritten event, a renumbered
|
|
42
|
+
* suffix, or a chain built under a different secret.
|
|
43
|
+
*
|
|
44
|
+
* An empty event list is invalid: truncation-to-zero must not verify.
|
|
45
|
+
*/
|
|
46
|
+
export async function verifyAuditChain(
|
|
47
|
+
events: readonly AuditEvent[],
|
|
48
|
+
signingKey: SigningKeyProvider,
|
|
49
|
+
): Promise<ChainVerification> {
|
|
50
|
+
if (events.length === 0) {
|
|
51
|
+
return { valid: false, index: 0, reason: 'empty event list' };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const chainKey = await signingKey.sign(DOMAIN_CHAIN);
|
|
55
|
+
let prevChainHash = '';
|
|
56
|
+
|
|
57
|
+
for (const [i, event] of events.entries()) {
|
|
58
|
+
if (event.replayManifestPosition !== i) {
|
|
59
|
+
return {
|
|
60
|
+
valid: false,
|
|
61
|
+
index: i,
|
|
62
|
+
reason: `replayManifestPosition ${event.replayManifestPosition}, expected ${i}`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
const expected = computeChainHash(
|
|
66
|
+
chainPreimageFields(event),
|
|
67
|
+
prevChainHash,
|
|
68
|
+
chainKey,
|
|
69
|
+
);
|
|
70
|
+
if (!hexEqual(event.chainHash, expected)) {
|
|
71
|
+
return { valid: false, index: i, reason: 'chainHash mismatch' };
|
|
72
|
+
}
|
|
73
|
+
prevChainHash = event.chainHash;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { valid: true };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Recomputes the manifest's signature (which covers every manifest field,
|
|
81
|
+
* not just the Merkle root) and compares it in constant time.
|
|
82
|
+
*/
|
|
83
|
+
export async function verifyManifestSignature(
|
|
84
|
+
manifest: ReplayManifest,
|
|
85
|
+
signingKey: SigningKeyProvider,
|
|
86
|
+
): Promise<boolean> {
|
|
87
|
+
const { signature, ...unsigned } = manifest;
|
|
88
|
+
const expected = await signingKey.sign(
|
|
89
|
+
Buffer.from(manifestSigningPayload(unsigned)),
|
|
90
|
+
);
|
|
91
|
+
return hexEqual(signature, expected.toString('hex'));
|
|
92
|
+
}
|