protect-mcp 0.9.1 → 0.9.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +86 -0
- package/README.md +42 -2
- package/dist/{chunk-CXW2EIRM.mjs → chunk-7MHK5RF4.mjs} +2 -2
- package/dist/{chunk-744JMCY4.mjs → chunk-DX7QOA3E.mjs} +2 -2
- package/dist/{chunk-KMNXHGGT.mjs → chunk-HPKHMVZX.mjs} +1 -1
- package/dist/{chunk-GHR65WVD.mjs → chunk-PB3TC7E3.mjs} +1 -1
- package/dist/{chunk-IDUH2O4Q.mjs → chunk-XLJUZ4WO.mjs} +7 -1
- package/dist/claim-3EBRPP5U.mjs +276 -0
- package/dist/cli.js +284 -14
- package/dist/cli.mjs +134 -17
- package/dist/hook-server.js +8 -2
- package/dist/hook-server.mjs +3 -3
- package/dist/{http-transport-D7C64PIA.mjs → http-transport-HLSMVBI6.mjs} +2 -2
- package/dist/index.d.mts +12 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +8 -2
- package/dist/index.mjs +5 -5
- package/package.json +1 -1
- package/dist/claim-U7Q3A4WW.mjs +0 -129
- package/policies/agent.cedar +0 -50
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,91 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.3: the skeptic's tools sharpen
|
|
4
|
+
|
|
5
|
+
Two verification upgrades: the anchor check moves into the verifier, and the
|
|
6
|
+
record viewer proves signatures instead of just labelling them.
|
|
7
|
+
|
|
8
|
+
- `verify-claim` now verifies the anchor automatically: it finds the
|
|
9
|
+
`<claim>.anchor.json` sidecar, checks offline that the anchored envelope binds
|
|
10
|
+
this exact claim (digest, record root, and the same issuer key), and confirms
|
|
11
|
+
against the public log that the digest sits at the recorded entry. A failed
|
|
12
|
+
binding makes the attestation INVALID; an unreachable log does not (the local
|
|
13
|
+
checks stand alone). `--check-anchor` makes a missing anchor fatal,
|
|
14
|
+
`--anchor-file <p>` overrides the path, `--offline` skips the network hop.
|
|
15
|
+
- The `record` viewer now verifies Ed25519 signatures in the browser, locally:
|
|
16
|
+
each row shows `✓ verified`, `✗ invalid signature`, or `signed · unpinned
|
|
17
|
+
key`, and the stat strip counts how many verified. The CLI injects your
|
|
18
|
+
gateway's PUBLIC key so rows verify against your own key (the private key
|
|
19
|
+
never reaches the page).
|
|
20
|
+
- Receipts now embed the signer's public key inside the signed payload
|
|
21
|
+
(`payload.public_key`), so a receipt is self-contained: any verifier can check
|
|
22
|
+
it without a side channel, and the key cannot be swapped without breaking the
|
|
23
|
+
signature. Older receipts still verify against a pinned or pasted key.
|
|
24
|
+
|
|
25
|
+
## 0.9.2: anchor a claim to the public log
|
|
26
|
+
|
|
27
|
+
Closes the one honest gap in a bare claim: that the disclosed set is complete.
|
|
28
|
+
|
|
29
|
+
- Added `protect-mcp claim --anchor`: records the claim's digest in the public,
|
|
30
|
+
append-only ScopeBlind transparency log, so a counterparty who does not trust
|
|
31
|
+
you can confirm the exact claim existed at a time and was not quietly re-cut.
|
|
32
|
+
Only the hash is sent; the claim, its leaves, and every receipt stay on your
|
|
33
|
+
machine. Writes a `<claim>.anchor.json` sidecar with the log entry and a
|
|
34
|
+
verify URL. Anonymous and free; enrolling an org identity a counterparty can
|
|
35
|
+
pin is the paid upgrade (scopeblind.com/enroll).
|
|
36
|
+
|
|
37
|
+
## 0.9.1: prove a claim without revealing the record
|
|
38
|
+
|
|
39
|
+
The record viewer closes the loop from a decision to a portable proof.
|
|
40
|
+
|
|
41
|
+
- The `record` viewer surfaces the exact `claim` command when you filter by a
|
|
42
|
+
capability, so a decision becomes a signed, position-blind attestation in one
|
|
43
|
+
step, with copy-to-clipboard and an offline `verify-claim` hint.
|
|
44
|
+
- The browser drop-viewer (legate.scopeblind.com/record) reached parity with
|
|
45
|
+
the CLI: signed capability tags, a capability facet, and a provenance tree.
|
|
46
|
+
|
|
47
|
+
## 0.9.0: signed, position-blind claim attestations
|
|
48
|
+
|
|
49
|
+
Prove a predicate over the record without revealing it.
|
|
50
|
+
|
|
51
|
+
- Added `protect-mcp claim`: mints a signed attestation of a predicate over the
|
|
52
|
+
record (`--no <cap>` for "no action carried this capability", `--only`,
|
|
53
|
+
`--count`/`--no-verdict <verdict>`), disclosing only per-decision categories
|
|
54
|
+
(receipt digest, verdict, capability tags), never inputs, outputs, or data.
|
|
55
|
+
- Added `protect-mcp verify-claim`: checks it offline via Ed25519 over the pack,
|
|
56
|
+
a recomputed Merkle root over the disclosed set, and the predicate recomputed
|
|
57
|
+
independently. Honest trust model: an accountable position-blind attestation,
|
|
58
|
+
not zero-knowledge; set-completeness is issuer-attested.
|
|
59
|
+
|
|
60
|
+
## 0.8.0: deterministic receipt enrichment
|
|
61
|
+
|
|
62
|
+
Each signed decision now carries minimum-disclosure, rule-derived metadata.
|
|
63
|
+
|
|
64
|
+
- `input_digest`: SHA-256 of the canonicalized tool input, so you can prove what
|
|
65
|
+
was requested without storing it.
|
|
66
|
+
- Signed `capabilities`: deterministic, rule-derived tags (fs.read, fs.write,
|
|
67
|
+
net.egress, secret.adjacent, destructive, financial, exec.shell, ...) so the
|
|
68
|
+
record is queryable by what an action touched.
|
|
69
|
+
- `resource`: the hashed target (path, host, or command). The gate sees tool
|
|
70
|
+
calls, not the model's prompt or reasoning, and does not claim to.
|
|
71
|
+
|
|
72
|
+
## 0.7.6: the live record viewer
|
|
73
|
+
|
|
74
|
+
- Added `protect-mcp record --live`: a local, streaming view of the receipt
|
|
75
|
+
file that turns decisions into a searchable, auto-labelled record as the agent
|
|
76
|
+
runs (stat strip, per-row signed badge and digest, a List/Tree provenance
|
|
77
|
+
view), all local, nothing uploaded.
|
|
78
|
+
- One-click export: `.jsonl` carrying the real signatures (so a recipient
|
|
79
|
+
verifies offline with `npx @veritasacta/verify`) plus a Markdown report.
|
|
80
|
+
|
|
81
|
+
## 0.7.5: honest version strings and the record viewer
|
|
82
|
+
|
|
83
|
+
- Added `protect-mcp record`, a local browser viewer over the receipts file
|
|
84
|
+
(the answer to "the receipts are a hidden dotfile I cannot find").
|
|
85
|
+
- Fixed hard-coded version strings in the banner and health/serverInfo: they
|
|
86
|
+
read the resolved package version now.
|
|
87
|
+
- WebAuthn co-sign hardening: verify the rpIdHash and enforce challenge expiry.
|
|
88
|
+
|
|
3
89
|
## 0.7.4: the self-serve trial path
|
|
4
90
|
|
|
5
91
|
Everything the scopeblind.com trial and docs pages describe now ships in the
|
package/README.md
CHANGED
|
@@ -148,6 +148,43 @@ disclosure, not full zero-knowledge, but it makes the privacy claim concrete:
|
|
|
148
148
|
auditors can verify selected facts without receiving the full tool payload or
|
|
149
149
|
sensitive desk context.
|
|
150
150
|
|
|
151
|
+
### Prove a claim over the record (position-blind attestations)
|
|
152
|
+
|
|
153
|
+
You can prove a CLAIM over your record without revealing it. Mint a signed,
|
|
154
|
+
position-blind attestation over the whole record that discloses only per-decision
|
|
155
|
+
categories (a receipt digest, the verdict, capability tags), never your tool
|
|
156
|
+
inputs, outputs, or data:
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
# "No action reached the network across the record":
|
|
160
|
+
npx protect-mcp claim --no net.egress
|
|
161
|
+
|
|
162
|
+
# other predicates:
|
|
163
|
+
# --only fs.read,fs.write all actions were confined to these capabilities
|
|
164
|
+
# --no-verdict blocked no action was blocked
|
|
165
|
+
# --count blocked how many were blocked
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Anyone verifies it offline, seeing only the categories, never the content:
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
npx protect-mcp verify-claim claim-<id>.json
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
The verifier recomputes a Merkle root over the disclosed set and recomputes the
|
|
175
|
+
predicate independently, so the issuer cannot lie about the claim given the
|
|
176
|
+
disclosure. Add `--anchor` to record the claim's digest in the public,
|
|
177
|
+
append-only ScopeBlind transparency log, so a counterparty who does not trust you
|
|
178
|
+
can confirm the disclosed set is complete and was not quietly re-cut (only the
|
|
179
|
+
hash is sent; the record stays local):
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
npx protect-mcp claim --no net.egress --anchor
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
This is an accountable, position-blind attestation, not full zero-knowledge: it
|
|
186
|
+
reveals the shape, not the content.
|
|
187
|
+
|
|
151
188
|
## Claude Code hook quickstart
|
|
152
189
|
|
|
153
190
|
```bash
|
|
@@ -189,7 +226,7 @@ session always runs the gate you tested:
|
|
|
189
226
|
"hooks": [
|
|
190
227
|
{
|
|
191
228
|
"type": "command",
|
|
192
|
-
"command": "npx protect-mcp@0.
|
|
229
|
+
"command": "npx protect-mcp@0.9.1 evaluate --cedar ./cedar --tool \"$TOOL_NAME\" --input \"$TOOL_INPUT\""
|
|
193
230
|
}
|
|
194
231
|
]
|
|
195
232
|
}
|
|
@@ -200,7 +237,7 @@ session always runs the gate you tested:
|
|
|
200
237
|
"hooks": [
|
|
201
238
|
{
|
|
202
239
|
"type": "command",
|
|
203
|
-
"command": "npx protect-mcp@0.
|
|
240
|
+
"command": "npx protect-mcp@0.9.1 sign --tool \"$TOOL_NAME\" --receipts ./receipts --key ./keys/gateway.json"
|
|
204
241
|
}
|
|
205
242
|
]
|
|
206
243
|
}
|
|
@@ -335,6 +372,9 @@ To report a vulnerability, see [SECURITY.md](./SECURITY.md).
|
|
|
335
372
|
| `dashboard` | Start a local-only dashboard on `127.0.0.1` showing tool inventory, risk, policy coverage, exact-action approvals, receipt chains, and audit export. |
|
|
336
373
|
| `recommend` | Draft a reviewable JSON policy from observed local calls. Dry-run by default; use `--write` to create `protect-mcp.recommended.json`. |
|
|
337
374
|
| `registry` | Create an org identity, anchor receipt digests, and write a static verifier page. Hosted mode uploads digests only. |
|
|
375
|
+
| `record` | Open a local, searchable viewer over your receipts (`--live` streams as the agent runs): Ed25519 signatures verified in your browser against your gateway key, capability tags, a provenance tree, and one-click signed export. All local, nothing uploaded. |
|
|
376
|
+
| `claim` | Mint a signed, position-blind attestation of a predicate over the record (`--no <cap>`, `--only <c1,c2>`, `--no-verdict <verdict>`, `--count <verdict>`), disclosing only decision categories. Add `--anchor` to record the claim digest in the public transparency log. |
|
|
377
|
+
| `verify-claim` | Verify a claim pack offline: signature, recomputed Merkle root, independently recomputed predicate, and the anchor sidecar when present (binds the anchored envelope to this exact claim, then confirms the public log holds it). `--check-anchor` requires the anchor; `--offline` skips the log hop. |
|
|
338
378
|
| `killer-demo` | Generate a complete shadow-mode to policy to approval to signed-receipt demo pack. |
|
|
339
379
|
| `verify-disclosure` | Verify a `scopeblind.selective_disclosure.v0` package and explain disclosed versus hidden fields. |
|
|
340
380
|
| `policy-packs` | List, inspect, and install starter Cedar policy packs. |
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
2
|
meetsMinTier
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-PB3TC7E3.mjs";
|
|
4
4
|
import {
|
|
5
5
|
checkRateLimit,
|
|
6
6
|
getToolPolicy,
|
|
7
7
|
parseRateLimit
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-XLJUZ4WO.mjs";
|
|
9
9
|
|
|
10
10
|
// src/simulate.ts
|
|
11
11
|
import { readFileSync } from "fs";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
buildEnrichment
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-HPKHMVZX.mjs";
|
|
4
4
|
import {
|
|
5
5
|
ReceiptBuffer,
|
|
6
6
|
buildActionReadback,
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
loadPolicy,
|
|
16
16
|
parseRateLimit,
|
|
17
17
|
signDecision
|
|
18
|
-
} from "./chunk-
|
|
18
|
+
} from "./chunk-XLJUZ4WO.mjs";
|
|
19
19
|
|
|
20
20
|
// src/hook-server.ts
|
|
21
21
|
import { createServer } from "http";
|
|
@@ -177,7 +177,13 @@ function signDecision(entry) {
|
|
|
177
177
|
// - scopeblind:verified = issued via ScopeBlind VOPRF backend (paid tier)
|
|
178
178
|
// - self-signed = signed with local Ed25519 key (free tier, protect-mcp default)
|
|
179
179
|
// - uncertified = unsigned receipt (shadow mode, no signing configured)
|
|
180
|
-
issuer_certification: signerState ? "self-signed" : "uncertified"
|
|
180
|
+
issuer_certification: signerState ? "self-signed" : "uncertified",
|
|
181
|
+
// The signer's PUBLIC key, inside the signed payload, so a receipt is
|
|
182
|
+
// self-contained: any verifier (including the record viewer, in-browser)
|
|
183
|
+
// can check the signature without a side channel. Binding the key inside
|
|
184
|
+
// the signature means it cannot be swapped without breaking the signature;
|
|
185
|
+
// authenticity (that the key is YOUR gate's) still comes from pinning it.
|
|
186
|
+
public_key: signerState.publicKey
|
|
181
187
|
};
|
|
182
188
|
if (entry.tier) payload.tier = entry.tier;
|
|
183
189
|
if (entry.credential_ref) payload.credential_ref = entry.credential_ref;
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import {
|
|
2
|
+
canonicalJson
|
|
3
|
+
} from "./chunk-HPKHMVZX.mjs";
|
|
4
|
+
import {
|
|
5
|
+
sha256
|
|
6
|
+
} from "./chunk-AYNQIEN7.mjs";
|
|
7
|
+
import {
|
|
8
|
+
ed25519
|
|
9
|
+
} from "./chunk-FFVJL3KQ.mjs";
|
|
10
|
+
import "./chunk-JIDDQUSQ.mjs";
|
|
11
|
+
import {
|
|
12
|
+
bytesToHex,
|
|
13
|
+
hexToBytes
|
|
14
|
+
} from "./chunk-D733KAPG.mjs";
|
|
15
|
+
import "./chunk-PQJP2ZCI.mjs";
|
|
16
|
+
|
|
17
|
+
// src/claim.ts
|
|
18
|
+
var CLAIM_TYPE = "scopeblind.claim.v1";
|
|
19
|
+
function sha256Hex(input) {
|
|
20
|
+
const bytes = typeof input === "string" ? new TextEncoder().encode(input) : input;
|
|
21
|
+
return bytesToHex(sha256(bytes));
|
|
22
|
+
}
|
|
23
|
+
function receiptToLeaf(e) {
|
|
24
|
+
const p = e && typeof e.payload === "object" && e.payload || e;
|
|
25
|
+
const dec = String(p.decision || e.decision || "").toLowerCase();
|
|
26
|
+
const v = /den|block|reject|refus/.test(dec) ? "blocked" : /ask|approv|hold|escal|review|pending/.test(dec) ? "held" : "allowed";
|
|
27
|
+
const enr = p.enrichment;
|
|
28
|
+
const c = enr && Array.isArray(enr.capabilities) ? enr.capabilities.map(String).sort() : [];
|
|
29
|
+
const tsRaw = e.issued_at || p.timestamp || p.issued_at;
|
|
30
|
+
const ms = typeof tsRaw === "number" ? tsRaw : typeof tsRaw === "string" ? Date.parse(tsRaw) : NaN;
|
|
31
|
+
const t = isFinite(ms) ? new Date(ms).toISOString() : "";
|
|
32
|
+
const d = sha256Hex(canonicalJson(e));
|
|
33
|
+
return { d, v, c, t };
|
|
34
|
+
}
|
|
35
|
+
function leafHash(leaf) {
|
|
36
|
+
return sha256Hex(canonicalJson(leaf));
|
|
37
|
+
}
|
|
38
|
+
function merkleRoot(leafHashes) {
|
|
39
|
+
if (leafHashes.length === 0) return sha256Hex("scopeblind.claim.empty");
|
|
40
|
+
let level = [...leafHashes].sort();
|
|
41
|
+
while (level.length > 1) {
|
|
42
|
+
const next = [];
|
|
43
|
+
for (let i = 0; i < level.length; i += 2) {
|
|
44
|
+
const a = level[i];
|
|
45
|
+
const b = i + 1 < level.length ? level[i + 1] : level[i];
|
|
46
|
+
next.push(sha256Hex(a + b));
|
|
47
|
+
}
|
|
48
|
+
level = next;
|
|
49
|
+
}
|
|
50
|
+
return level[0];
|
|
51
|
+
}
|
|
52
|
+
function evaluate(pred, leaves) {
|
|
53
|
+
if (pred.kind === "no_capability") {
|
|
54
|
+
const matched2 = leaves.filter((l) => l.c.indexOf(pred.capability) >= 0).length;
|
|
55
|
+
return { statement: `No action used capability "${pred.capability}"`, holds: matched2 === 0, matched: matched2 };
|
|
56
|
+
}
|
|
57
|
+
if (pred.kind === "only_capabilities") {
|
|
58
|
+
const allow = new Set(pred.capabilities);
|
|
59
|
+
const matched2 = leaves.filter((l) => !l.c.every((c) => allow.has(c))).length;
|
|
60
|
+
return { statement: `All actions were confined to capabilities {${pred.capabilities.join(", ")}}`, holds: matched2 === 0, matched: matched2 };
|
|
61
|
+
}
|
|
62
|
+
if (pred.kind === "no_verdict") {
|
|
63
|
+
const matched2 = leaves.filter((l) => l.v === pred.verdict).length;
|
|
64
|
+
return { statement: `No action was ${pred.verdict}`, holds: matched2 === 0, matched: matched2 };
|
|
65
|
+
}
|
|
66
|
+
const matched = leaves.filter((l) => l.v === pred.verdict).length;
|
|
67
|
+
return { statement: `${matched} action${matched === 1 ? " was" : "s were"} ${pred.verdict}`, holds: true, matched };
|
|
68
|
+
}
|
|
69
|
+
function messageHash(unsigned) {
|
|
70
|
+
return sha256(new TextEncoder().encode(canonicalJson(unsigned)));
|
|
71
|
+
}
|
|
72
|
+
function buildClaim(receipts, predicate, key, issuedAt) {
|
|
73
|
+
const leaves = receipts.map(receiptToLeaf);
|
|
74
|
+
const root = merkleRoot(leaves.map(leafHash));
|
|
75
|
+
const claim = evaluate(predicate, leaves);
|
|
76
|
+
const times = leaves.map((l) => l.t).filter(Boolean).sort();
|
|
77
|
+
const unsigned = {
|
|
78
|
+
type: CLAIM_TYPE,
|
|
79
|
+
predicate,
|
|
80
|
+
claim,
|
|
81
|
+
scope: { total: leaves.length, from: times[0] || "", to: times[times.length - 1] || "" },
|
|
82
|
+
record: { root },
|
|
83
|
+
leaves,
|
|
84
|
+
issuer: { kid: key.kid, publicKey: key.publicKey, issuer: key.issuer || "protect-mcp" },
|
|
85
|
+
issued_at: issuedAt
|
|
86
|
+
};
|
|
87
|
+
const signature = bytesToHex(ed25519.sign(messageHash(unsigned), hexToBytes(key.privateKey)));
|
|
88
|
+
return { ...unsigned, signature };
|
|
89
|
+
}
|
|
90
|
+
function verifyClaim(pack, overridePublicKey) {
|
|
91
|
+
const reasons = [];
|
|
92
|
+
const leaves = Array.isArray(pack.leaves) ? pack.leaves : [];
|
|
93
|
+
const recomputedRoot = merkleRoot(leaves.map(leafHash));
|
|
94
|
+
const root_ok = !!pack.record && recomputedRoot === pack.record.root;
|
|
95
|
+
if (!root_ok) reasons.push("record commitment (Merkle root) does not match the disclosed decisions");
|
|
96
|
+
const recomputed = evaluate(pack.predicate, leaves);
|
|
97
|
+
const predicate_ok = !!pack.claim && recomputed.holds === pack.claim.holds && recomputed.matched === pack.claim.matched;
|
|
98
|
+
if (!predicate_ok) reasons.push("claim result does not match the predicate recomputed over the disclosed decisions");
|
|
99
|
+
let authentic = false;
|
|
100
|
+
try {
|
|
101
|
+
const { signature, ...unsigned } = pack;
|
|
102
|
+
const pub = overridePublicKey || pack.issuer && pack.issuer.publicKey;
|
|
103
|
+
if (pub && signature) {
|
|
104
|
+
authentic = ed25519.verify(hexToBytes(signature), messageHash(unsigned), hexToBytes(pub));
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
}
|
|
108
|
+
if (!authentic) reasons.push("signature does not verify against the issuer public key");
|
|
109
|
+
return {
|
|
110
|
+
valid: authentic && root_ok && predicate_ok,
|
|
111
|
+
authentic,
|
|
112
|
+
root_ok,
|
|
113
|
+
predicate_ok,
|
|
114
|
+
holds: !!(pack.claim && pack.claim.holds),
|
|
115
|
+
matched: pack.claim ? pack.claim.matched : recomputed.matched,
|
|
116
|
+
total: leaves.length,
|
|
117
|
+
statement: pack.claim ? pack.claim.statement : recomputed.statement,
|
|
118
|
+
reasons
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
var ANCHOR_SCHEMA = "scopeblind.protect-mcp.anchor.v1";
|
|
122
|
+
var DEFAULT_LOG = "https://scopeblind.com";
|
|
123
|
+
function anchorDeepSort(o) {
|
|
124
|
+
if (o === null || typeof o !== "object") return o;
|
|
125
|
+
if (Array.isArray(o)) return o.map(anchorDeepSort);
|
|
126
|
+
const src = o;
|
|
127
|
+
const out = {};
|
|
128
|
+
for (const k of Object.keys(src).sort()) out[k] = anchorDeepSort(src[k]);
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
function toBase64(bytes) {
|
|
132
|
+
let bin = "";
|
|
133
|
+
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
|
134
|
+
return btoa(bin);
|
|
135
|
+
}
|
|
136
|
+
function claimDigest(pack) {
|
|
137
|
+
return sha256Hex(canonicalJson(pack));
|
|
138
|
+
}
|
|
139
|
+
function buildAnchorEnvelope(pack, key, issuedAt) {
|
|
140
|
+
const signed = {
|
|
141
|
+
type: "evidence_pack",
|
|
142
|
+
schema: ANCHOR_SCHEMA,
|
|
143
|
+
anchors: "protect-mcp-claim",
|
|
144
|
+
claim_digest: claimDigest(pack),
|
|
145
|
+
record_root: pack.record.root,
|
|
146
|
+
statement: pack.claim.statement,
|
|
147
|
+
holds: pack.claim.holds,
|
|
148
|
+
matched: pack.claim.matched,
|
|
149
|
+
total: pack.scope.total,
|
|
150
|
+
issued_at: issuedAt,
|
|
151
|
+
verification_key: key.publicKey,
|
|
152
|
+
disclosure: "internal"
|
|
153
|
+
};
|
|
154
|
+
const hash = sha256(new TextEncoder().encode(JSON.stringify(anchorDeepSort(signed))));
|
|
155
|
+
const digest = bytesToHex(hash);
|
|
156
|
+
const signature = bytesToHex(ed25519.sign(hash, hexToBytes(key.privateKey)));
|
|
157
|
+
return { ...signed, signature, digest };
|
|
158
|
+
}
|
|
159
|
+
function verifyAnchorEnvelope(pack, envelope) {
|
|
160
|
+
const reasons = [];
|
|
161
|
+
if (!envelope || envelope.type !== "evidence_pack" || envelope.anchors !== "protect-mcp-claim") {
|
|
162
|
+
return { ok: false, reasons: ["sidecar does not contain a protect-mcp claim anchor envelope"] };
|
|
163
|
+
}
|
|
164
|
+
const expected = claimDigest(pack);
|
|
165
|
+
if (envelope.claim_digest !== expected) {
|
|
166
|
+
reasons.push("anchored envelope binds a DIFFERENT claim (claim_digest mismatch)");
|
|
167
|
+
}
|
|
168
|
+
if (envelope.record_root !== pack.record.root) {
|
|
169
|
+
reasons.push("anchored envelope commits to a different record root");
|
|
170
|
+
}
|
|
171
|
+
if (pack.issuer && envelope.verification_key !== pack.issuer.publicKey) {
|
|
172
|
+
reasons.push("anchor was signed by a different key than the claim issuer");
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
const { signature, digest, ...signed } = envelope;
|
|
176
|
+
const hash = sha256(new TextEncoder().encode(JSON.stringify(anchorDeepSort(signed))));
|
|
177
|
+
if (bytesToHex(hash) !== String(digest).toLowerCase()) {
|
|
178
|
+
reasons.push("envelope digest does not match its contents");
|
|
179
|
+
} else if (!ed25519.verify(hexToBytes(String(signature)), hash, hexToBytes(envelope.verification_key))) {
|
|
180
|
+
reasons.push("envelope signature does not verify");
|
|
181
|
+
}
|
|
182
|
+
} catch {
|
|
183
|
+
reasons.push("envelope signature does not verify");
|
|
184
|
+
}
|
|
185
|
+
return { ok: reasons.length === 0, reasons };
|
|
186
|
+
}
|
|
187
|
+
async function checkClaimAnchor(pack, sidecar, opts) {
|
|
188
|
+
const reasons = [];
|
|
189
|
+
const envelope = sidecar && sidecar.envelope;
|
|
190
|
+
if (!envelope) {
|
|
191
|
+
return { local_ok: false, log_ok: null, reasons: ["sidecar has no anchor envelope"] };
|
|
192
|
+
}
|
|
193
|
+
const local = verifyAnchorEnvelope(pack, envelope);
|
|
194
|
+
reasons.push(...local.reasons);
|
|
195
|
+
const base = (sidecar.log || DEFAULT_LOG).replace(/\/+$/, "");
|
|
196
|
+
const out = {
|
|
197
|
+
local_ok: local.ok,
|
|
198
|
+
log_ok: null,
|
|
199
|
+
seq: sidecar.seq,
|
|
200
|
+
anchored_at: sidecar.anchored_at,
|
|
201
|
+
entry_url: sidecar.entry_url || (typeof sidecar.seq === "number" ? `${base}/fn/log/${sidecar.seq}` : void 0),
|
|
202
|
+
reasons
|
|
203
|
+
};
|
|
204
|
+
if (opts?.offline) return out;
|
|
205
|
+
const doFetch = opts?.fetchImpl || globalThis.fetch;
|
|
206
|
+
if (!doFetch) return out;
|
|
207
|
+
try {
|
|
208
|
+
const resp = await doFetch(`${base}/fn/log/digest/sha256:${envelope.digest}`, { headers: { accept: "application/json" } });
|
|
209
|
+
const data = await resp.json().catch(() => null);
|
|
210
|
+
if (!resp.ok || !data) {
|
|
211
|
+
out.log_ok = null;
|
|
212
|
+
return out;
|
|
213
|
+
}
|
|
214
|
+
if (data.anchored !== true) {
|
|
215
|
+
out.log_ok = false;
|
|
216
|
+
out.reasons.push("the public log does not contain this anchor digest");
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
219
|
+
if (typeof sidecar.seq === "number" && typeof data.seq === "number" && data.seq !== sidecar.seq) {
|
|
220
|
+
out.log_ok = false;
|
|
221
|
+
out.reasons.push(`log holds the digest at entry #${data.seq}, sidecar says #${sidecar.seq}`);
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
out.log_ok = true;
|
|
225
|
+
if (typeof data.seq === "number") out.seq = data.seq;
|
|
226
|
+
return out;
|
|
227
|
+
} catch {
|
|
228
|
+
out.log_ok = null;
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
async function anchorClaim(pack, key, opts) {
|
|
233
|
+
const envelope = buildAnchorEnvelope(pack, key, opts.issuedAt);
|
|
234
|
+
const base = (opts.log || DEFAULT_LOG).replace(/\/+$/, "");
|
|
235
|
+
const doFetch = opts.fetchImpl || globalThis.fetch;
|
|
236
|
+
if (!doFetch) return { ok: false, claim_digest: envelope.claim_digest, error: "fetch_unavailable", envelope };
|
|
237
|
+
const encoded = toBase64(new TextEncoder().encode(JSON.stringify(envelope)));
|
|
238
|
+
try {
|
|
239
|
+
const resp = await doFetch(`${base}/fn/log/anchor-pack`, {
|
|
240
|
+
method: "POST",
|
|
241
|
+
headers: { "content-type": "application/json" },
|
|
242
|
+
body: JSON.stringify({ encoded })
|
|
243
|
+
});
|
|
244
|
+
const data = await resp.json().catch(() => null);
|
|
245
|
+
if (!resp.ok || !data || !data.ok || typeof data.seq !== "number") {
|
|
246
|
+
return { ok: false, claim_digest: envelope.claim_digest, error: data && data.error || `http_${resp.status}`, envelope };
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
ok: true,
|
|
250
|
+
claim_digest: envelope.claim_digest,
|
|
251
|
+
seq: data.seq,
|
|
252
|
+
entry_url: `${base}/fn/log/${data.seq}`,
|
|
253
|
+
anchored_at: data.anchored_at,
|
|
254
|
+
already_anchored: !!data.already_anchored,
|
|
255
|
+
envelope
|
|
256
|
+
};
|
|
257
|
+
} catch {
|
|
258
|
+
return { ok: false, claim_digest: envelope.claim_digest, error: "network_error", envelope };
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
export {
|
|
262
|
+
ANCHOR_SCHEMA,
|
|
263
|
+
CLAIM_TYPE,
|
|
264
|
+
DEFAULT_LOG,
|
|
265
|
+
anchorClaim,
|
|
266
|
+
buildAnchorEnvelope,
|
|
267
|
+
buildClaim,
|
|
268
|
+
checkClaimAnchor,
|
|
269
|
+
claimDigest,
|
|
270
|
+
evaluate,
|
|
271
|
+
leafHash,
|
|
272
|
+
merkleRoot,
|
|
273
|
+
receiptToLeaf,
|
|
274
|
+
verifyAnchorEnvelope,
|
|
275
|
+
verifyClaim
|
|
276
|
+
};
|