protect-mcp 0.9.2 → 0.9.4
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 +49 -0
- package/README.md +41 -0
- package/dist/{chunk-CXW2EIRM.mjs → chunk-7MHK5RF4.mjs} +2 -2
- package/dist/{chunk-744JMCY4.mjs → chunk-H332ZNJ6.mjs} +2 -2
- package/dist/{chunk-GHR65WVD.mjs → chunk-PB3TC7E3.mjs} +1 -1
- package/dist/{chunk-KMNXHGGT.mjs → chunk-WWPQNIVF.mjs} +35 -2
- package/dist/{chunk-IDUH2O4Q.mjs → chunk-XLJUZ4WO.mjs} +7 -1
- package/dist/{claim-TUDH2WPB.mjs → claim-RIXFOQM7.mjs} +168 -16
- package/dist/cli.js +471 -33
- package/dist/cli.mjs +268 -21
- package/dist/hook-server.js +42 -3
- 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 +42 -3
- package/dist/index.mjs +5 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,54 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.4: agent payments get receipts, records get heartbeats, anchors get names
|
|
4
|
+
|
|
5
|
+
The provenance layer reaches the agentic economy's payment rails (x402), the
|
|
6
|
+
record gains continuous completeness, and anchoring can carry an identity.
|
|
7
|
+
|
|
8
|
+
- **Payment receipts (x402 interop).** The gate now tags agent payments with a
|
|
9
|
+
signed `payment` capability, detected broadly across x402 wire shapes
|
|
10
|
+
(`paymentRequirements`, `X-PAYMENT`, EIP-3009 `transferWithAuthorization`)
|
|
11
|
+
and payment-shaped tools. Receipts carry minimum-disclosure payment facts:
|
|
12
|
+
amount (only when clearly readable in human units), asset, a HASHED recipient,
|
|
13
|
+
and the x402 scheme. `claim --no payment` proves no agent paid anything;
|
|
14
|
+
`claim --payment-under <cap>` proves every payment stayed under a cap, and an
|
|
15
|
+
amount the gate could not read counts as OVER (you cannot prove an amount you
|
|
16
|
+
could not read, so the claim cannot lie).
|
|
17
|
+
- **Record checkpoints (`anchor-record`).** Anchors the record's CURRENT
|
|
18
|
+
commitment (the same Merkle root a claim commits to, plus count and time
|
|
19
|
+
range) into the public log. Run it on a heartbeat and the record grows an
|
|
20
|
+
anchored history: a later claim whose root matches a checkpoint is provably
|
|
21
|
+
over the complete set as of that checkpoint. Skips when the record is
|
|
22
|
+
unchanged; writes a local `.protect-mcp-anchors.jsonl` history; only the
|
|
23
|
+
root, count, and time range leave the machine.
|
|
24
|
+
- **Pinned identity in the anchor loop.** `claim --anchor`, `anchor-record`,
|
|
25
|
+
and `verify-claim` now resolve the anchoring key against the public ScopeBlind
|
|
26
|
+
key directory: an enrolled key shows "anchored as <Org> (key pinned)", a
|
|
27
|
+
revoked key fails verification, an anonymous key points at enrollment. The
|
|
28
|
+
free anchor stays anonymous; the named identity is the paid upgrade.
|
|
29
|
+
|
|
30
|
+
## 0.9.3: the skeptic's tools sharpen
|
|
31
|
+
|
|
32
|
+
Two verification upgrades: the anchor check moves into the verifier, and the
|
|
33
|
+
record viewer proves signatures instead of just labelling them.
|
|
34
|
+
|
|
35
|
+
- `verify-claim` now verifies the anchor automatically: it finds the
|
|
36
|
+
`<claim>.anchor.json` sidecar, checks offline that the anchored envelope binds
|
|
37
|
+
this exact claim (digest, record root, and the same issuer key), and confirms
|
|
38
|
+
against the public log that the digest sits at the recorded entry. A failed
|
|
39
|
+
binding makes the attestation INVALID; an unreachable log does not (the local
|
|
40
|
+
checks stand alone). `--check-anchor` makes a missing anchor fatal,
|
|
41
|
+
`--anchor-file <p>` overrides the path, `--offline` skips the network hop.
|
|
42
|
+
- The `record` viewer now verifies Ed25519 signatures in the browser, locally:
|
|
43
|
+
each row shows `✓ verified`, `✗ invalid signature`, or `signed · unpinned
|
|
44
|
+
key`, and the stat strip counts how many verified. The CLI injects your
|
|
45
|
+
gateway's PUBLIC key so rows verify against your own key (the private key
|
|
46
|
+
never reaches the page).
|
|
47
|
+
- Receipts now embed the signer's public key inside the signed payload
|
|
48
|
+
(`payload.public_key`), so a receipt is self-contained: any verifier can check
|
|
49
|
+
it without a side channel, and the key cannot be swapped without breaking the
|
|
50
|
+
signature. Older receipts still verify against a pinned or pasted key.
|
|
51
|
+
|
|
3
52
|
## 0.9.2: anchor a claim to the public log
|
|
4
53
|
|
|
5
54
|
Closes the one honest gap in a bare claim: that the disclosed set is complete.
|
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
|
|
@@ -335,6 +372,10 @@ 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>` incl. `--no payment`, `--only <c1,c2>`, `--no-verdict <verdict>`, `--count <verdict>`, `--payment-under <cap>`), disclosing only decision categories. Add `--anchor` to record the claim digest in the public transparency log; enrolled keys anchor as a named org. |
|
|
377
|
+
| `anchor-record` | Checkpoint the record's Merkle root + count + time range into the public log (heartbeat-friendly: skips when unchanged). A later claim whose commitment matches an anchored checkpoint is provably over the complete record as of that checkpoint. |
|
|
378
|
+
| `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
379
|
| `killer-demo` | Generate a complete shadow-mode to policy to approval to signed-receipt demo pack. |
|
|
339
380
|
| `verify-disclosure` | Verify a `scopeblind.selective_disclosure.v0` package and explain disclosed versus hidden fields. |
|
|
340
381
|
| `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-WWPQNIVF.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";
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
} from "./chunk-D733KAPG.mjs";
|
|
7
7
|
|
|
8
8
|
// src/receipt-enrichment.ts
|
|
9
|
-
var ENRICHMENT_VERSION =
|
|
9
|
+
var ENRICHMENT_VERSION = 2;
|
|
10
10
|
function canonicalJson(value) {
|
|
11
11
|
const seen = /* @__PURE__ */ new WeakSet();
|
|
12
12
|
const enc = (v) => {
|
|
@@ -43,7 +43,11 @@ var RULES = [
|
|
|
43
43
|
{ cap: "secret.adjacent", text: /\.env\b|secret|credential|passwd|password|api[_-]?key|private[_-]?key|\.pem\b|\.key\b|id_rsa|bearer\s|aws_(access|secret)|authorization/ },
|
|
44
44
|
{ cap: "destructive", text: /rm\s+-[a-z]*[rf]|\brmdir\b|drop\s+table|truncate\s+table|delete\s+from|reset\s+--hard|--force\b|\bmkfs\b|\bdd\s+if=|shutdown|reboot|kill\s+-9|>\s*\/dev\/sd/ },
|
|
45
45
|
{ cap: "financial", text: /\b(order|trade|buy|sell|transfer|wire|payment|withdraw|deposit|swap|invoice|charge|refund|settle)\b/ },
|
|
46
|
-
{ cap: "data.query", text: /\bselect\s+[\s\S]+\bfrom\b|\binsert\s+into\b|\bupdate\s+[\s\S]+\bset\b|\bdelete\s+from\b/ }
|
|
46
|
+
{ cap: "data.query", text: /\bselect\s+[\s\S]+\bfrom\b|\binsert\s+into\b|\bupdate\s+[\s\S]+\bset\b|\bdelete\s+from\b/ },
|
|
47
|
+
// Agent payments (x402 / value transfer). Deliberately BROAD: a false positive
|
|
48
|
+
// only makes a `claim --no payment` harder to assert (conservative); a false
|
|
49
|
+
// negative would let a real payment escape the record's payment claims.
|
|
50
|
+
{ cap: "payment", tool: /(^|[_.-])(pay|payment|x402|checkout)($|[_.-])|wallet.*send|send.*payment/, text: /x402|x-payment|paymentrequirements|maxamountrequired|payto|"pay_to"|eip-3009|transferwithauthorization|payment_intent|send_payment|create_payment/ }
|
|
47
51
|
];
|
|
48
52
|
function deriveCapabilities(tool, input) {
|
|
49
53
|
const t = String(tool || "").toLowerCase();
|
|
@@ -77,6 +81,33 @@ function deriveResource(input) {
|
|
|
77
81
|
}
|
|
78
82
|
return void 0;
|
|
79
83
|
}
|
|
84
|
+
function findField(input, names, depth = 0) {
|
|
85
|
+
if (depth > 4 || input === null || typeof input !== "object") return void 0;
|
|
86
|
+
const o = input;
|
|
87
|
+
const keys = Object.keys(o).sort();
|
|
88
|
+
for (const k of keys) {
|
|
89
|
+
if (names.indexOf(k.toLowerCase()) >= 0 && o[k] !== void 0 && o[k] !== null) return o[k];
|
|
90
|
+
}
|
|
91
|
+
for (const k of keys) {
|
|
92
|
+
const v = findField(o[k], names, depth + 1);
|
|
93
|
+
if (v !== void 0) return v;
|
|
94
|
+
}
|
|
95
|
+
return void 0;
|
|
96
|
+
}
|
|
97
|
+
function derivePayment(tool, input) {
|
|
98
|
+
if (deriveCapabilities(tool, input).indexOf("payment") < 0) return void 0;
|
|
99
|
+
const p = { amount: null, asset: null, recipient_digest: null };
|
|
100
|
+
const amt = findField(input, ["amount"]);
|
|
101
|
+
if (typeof amt === "number" && Number.isFinite(amt) && amt >= 0) p.amount = amt;
|
|
102
|
+
else if (typeof amt === "string" && /^\d{1,15}(\.\d{1,18})?$/.test(amt.trim()) && amt.indexOf(".") >= 0) p.amount = parseFloat(amt);
|
|
103
|
+
const asset = findField(input, ["asset", "currency", "token"]);
|
|
104
|
+
if (typeof asset === "string" && asset.trim()) p.asset = asset.trim().slice(0, 64);
|
|
105
|
+
const to = findField(input, ["payto", "pay_to", "recipient", "destination", "to"]);
|
|
106
|
+
if (typeof to === "string" && to.trim()) p.recipient_digest = sha256Hex(to.trim().toLowerCase());
|
|
107
|
+
const scheme = findField(input, ["scheme"]);
|
|
108
|
+
if (typeof scheme === "string" && scheme.trim()) p.scheme = scheme.trim().slice(0, 32);
|
|
109
|
+
return p;
|
|
110
|
+
}
|
|
80
111
|
function buildEnrichment(tool, input) {
|
|
81
112
|
const e = {
|
|
82
113
|
v: ENRICHMENT_VERSION,
|
|
@@ -85,6 +116,8 @@ function buildEnrichment(tool, input) {
|
|
|
85
116
|
};
|
|
86
117
|
const resource = deriveResource(input);
|
|
87
118
|
if (resource) e.resource = resource;
|
|
119
|
+
const payment = derivePayment(tool, input);
|
|
120
|
+
if (payment) e.payment = payment;
|
|
88
121
|
return e;
|
|
89
122
|
}
|
|
90
123
|
|
|
@@ -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;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
canonicalJson
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-WWPQNIVF.mjs";
|
|
4
4
|
import {
|
|
5
5
|
sha256
|
|
6
6
|
} from "./chunk-AYNQIEN7.mjs";
|
|
@@ -30,7 +30,13 @@ function receiptToLeaf(e) {
|
|
|
30
30
|
const ms = typeof tsRaw === "number" ? tsRaw : typeof tsRaw === "string" ? Date.parse(tsRaw) : NaN;
|
|
31
31
|
const t = isFinite(ms) ? new Date(ms).toISOString() : "";
|
|
32
32
|
const d = sha256Hex(canonicalJson(e));
|
|
33
|
-
|
|
33
|
+
const leaf = { d, v, c, t };
|
|
34
|
+
const pay = enr && enr.payment;
|
|
35
|
+
if (pay && typeof pay === "object") {
|
|
36
|
+
const amt = pay.amount;
|
|
37
|
+
leaf.p = typeof amt === "number" && Number.isFinite(amt) ? amt : null;
|
|
38
|
+
}
|
|
39
|
+
return leaf;
|
|
34
40
|
}
|
|
35
41
|
function leafHash(leaf) {
|
|
36
42
|
return sha256Hex(canonicalJson(leaf));
|
|
@@ -63,6 +69,10 @@ function evaluate(pred, leaves) {
|
|
|
63
69
|
const matched2 = leaves.filter((l) => l.v === pred.verdict).length;
|
|
64
70
|
return { statement: `No action was ${pred.verdict}`, holds: matched2 === 0, matched: matched2 };
|
|
65
71
|
}
|
|
72
|
+
if (pred.kind === "payment_under") {
|
|
73
|
+
const matched2 = leaves.filter((l) => "p" in l && (l.p === null || l.p >= pred.cap)).length;
|
|
74
|
+
return { statement: `Every payment stayed under ${pred.cap} (unknown amounts count as over)`, holds: matched2 === 0, matched: matched2 };
|
|
75
|
+
}
|
|
66
76
|
const matched = leaves.filter((l) => l.v === pred.verdict).length;
|
|
67
77
|
return { statement: `${matched} action${matched === 1 ? " was" : "s were"} ${pred.verdict}`, holds: true, matched };
|
|
68
78
|
}
|
|
@@ -156,11 +166,82 @@ function buildAnchorEnvelope(pack, key, issuedAt) {
|
|
|
156
166
|
const signature = bytesToHex(ed25519.sign(hash, hexToBytes(key.privateKey)));
|
|
157
167
|
return { ...signed, signature, digest };
|
|
158
168
|
}
|
|
159
|
-
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
169
|
+
function verifyAnchorEnvelope(pack, envelope) {
|
|
170
|
+
const reasons = [];
|
|
171
|
+
if (!envelope || envelope.type !== "evidence_pack" || envelope.anchors !== "protect-mcp-claim") {
|
|
172
|
+
return { ok: false, reasons: ["sidecar does not contain a protect-mcp claim anchor envelope"] };
|
|
173
|
+
}
|
|
174
|
+
const expected = claimDigest(pack);
|
|
175
|
+
if (envelope.claim_digest !== expected) {
|
|
176
|
+
reasons.push("anchored envelope binds a DIFFERENT claim (claim_digest mismatch)");
|
|
177
|
+
}
|
|
178
|
+
if (envelope.record_root !== pack.record.root) {
|
|
179
|
+
reasons.push("anchored envelope commits to a different record root");
|
|
180
|
+
}
|
|
181
|
+
if (pack.issuer && envelope.verification_key !== pack.issuer.publicKey) {
|
|
182
|
+
reasons.push("anchor was signed by a different key than the claim issuer");
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
const { signature, digest, ...signed } = envelope;
|
|
186
|
+
const hash = sha256(new TextEncoder().encode(JSON.stringify(anchorDeepSort(signed))));
|
|
187
|
+
if (bytesToHex(hash) !== String(digest).toLowerCase()) {
|
|
188
|
+
reasons.push("envelope digest does not match its contents");
|
|
189
|
+
} else if (!ed25519.verify(hexToBytes(String(signature)), hash, hexToBytes(envelope.verification_key))) {
|
|
190
|
+
reasons.push("envelope signature does not verify");
|
|
191
|
+
}
|
|
192
|
+
} catch {
|
|
193
|
+
reasons.push("envelope signature does not verify");
|
|
194
|
+
}
|
|
195
|
+
return { ok: reasons.length === 0, reasons };
|
|
196
|
+
}
|
|
197
|
+
async function checkClaimAnchor(pack, sidecar, opts) {
|
|
198
|
+
const reasons = [];
|
|
199
|
+
const envelope = sidecar && sidecar.envelope;
|
|
200
|
+
if (!envelope) {
|
|
201
|
+
return { local_ok: false, log_ok: null, reasons: ["sidecar has no anchor envelope"] };
|
|
202
|
+
}
|
|
203
|
+
const local = verifyAnchorEnvelope(pack, envelope);
|
|
204
|
+
reasons.push(...local.reasons);
|
|
205
|
+
const base = (sidecar.log || DEFAULT_LOG).replace(/\/+$/, "");
|
|
206
|
+
const out = {
|
|
207
|
+
local_ok: local.ok,
|
|
208
|
+
log_ok: null,
|
|
209
|
+
seq: sidecar.seq,
|
|
210
|
+
anchored_at: sidecar.anchored_at,
|
|
211
|
+
entry_url: sidecar.entry_url || (typeof sidecar.seq === "number" ? `${base}/fn/log/${sidecar.seq}` : void 0),
|
|
212
|
+
reasons
|
|
213
|
+
};
|
|
214
|
+
if (opts?.offline) return out;
|
|
215
|
+
const doFetch = opts?.fetchImpl || globalThis.fetch;
|
|
216
|
+
if (!doFetch) return out;
|
|
217
|
+
try {
|
|
218
|
+
const resp = await doFetch(`${base}/fn/log/digest/sha256:${envelope.digest}`, { headers: { accept: "application/json" } });
|
|
219
|
+
const data = await resp.json().catch(() => null);
|
|
220
|
+
if (!resp.ok || !data) {
|
|
221
|
+
out.log_ok = null;
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
if (data.anchored !== true) {
|
|
225
|
+
out.log_ok = false;
|
|
226
|
+
out.reasons.push("the public log does not contain this anchor digest");
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
if (typeof sidecar.seq === "number" && typeof data.seq === "number" && data.seq !== sidecar.seq) {
|
|
230
|
+
out.log_ok = false;
|
|
231
|
+
out.reasons.push(`log holds the digest at entry #${data.seq}, sidecar says #${sidecar.seq}`);
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
out.log_ok = true;
|
|
235
|
+
if (typeof data.seq === "number") out.seq = data.seq;
|
|
236
|
+
return out;
|
|
237
|
+
} catch {
|
|
238
|
+
out.log_ok = null;
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
async function submitEnvelope(envelope, base, fetchImpl) {
|
|
243
|
+
const doFetch = fetchImpl || globalThis.fetch;
|
|
244
|
+
if (!doFetch) return { ok: false, error: "fetch_unavailable" };
|
|
164
245
|
const encoded = toBase64(new TextEncoder().encode(JSON.stringify(envelope)));
|
|
165
246
|
try {
|
|
166
247
|
const resp = await doFetch(`${base}/fn/log/anchor-pack`, {
|
|
@@ -170,32 +251,103 @@ async function anchorClaim(pack, key, opts) {
|
|
|
170
251
|
});
|
|
171
252
|
const data = await resp.json().catch(() => null);
|
|
172
253
|
if (!resp.ok || !data || !data.ok || typeof data.seq !== "number") {
|
|
173
|
-
return { ok: false,
|
|
254
|
+
return { ok: false, error: data && data.error || `http_${resp.status}` };
|
|
174
255
|
}
|
|
256
|
+
return { ok: true, seq: data.seq, anchored_at: data.anchored_at, already_anchored: !!data.already_anchored };
|
|
257
|
+
} catch {
|
|
258
|
+
return { ok: false, error: "network_error" };
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
async function anchorClaim(pack, key, opts) {
|
|
262
|
+
const envelope = buildAnchorEnvelope(pack, key, opts.issuedAt);
|
|
263
|
+
const base = (opts.log || DEFAULT_LOG).replace(/\/+$/, "");
|
|
264
|
+
const out = await submitEnvelope(envelope, base, opts.fetchImpl);
|
|
265
|
+
if (!out.ok) return { ok: false, claim_digest: envelope.claim_digest, error: out.error, envelope };
|
|
266
|
+
return {
|
|
267
|
+
ok: true,
|
|
268
|
+
claim_digest: envelope.claim_digest,
|
|
269
|
+
seq: out.seq,
|
|
270
|
+
entry_url: `${base}/fn/log/${out.seq}`,
|
|
271
|
+
anchored_at: out.anchored_at,
|
|
272
|
+
already_anchored: out.already_anchored,
|
|
273
|
+
envelope
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
var CHECKPOINT_SCHEMA = "scopeblind.protect-mcp.record-checkpoint.v1";
|
|
277
|
+
function buildRecordCheckpoint(receipts, key, issuedAt) {
|
|
278
|
+
const leaves = receipts.map(receiptToLeaf);
|
|
279
|
+
const times = leaves.map((l) => l.t).filter(Boolean).sort();
|
|
280
|
+
const signed = {
|
|
281
|
+
type: "evidence_pack",
|
|
282
|
+
schema: CHECKPOINT_SCHEMA,
|
|
283
|
+
anchors: "protect-mcp-record",
|
|
284
|
+
record_root: merkleRoot(leaves.map(leafHash)),
|
|
285
|
+
total: leaves.length,
|
|
286
|
+
from: times[0] || "",
|
|
287
|
+
to: times[times.length - 1] || "",
|
|
288
|
+
issued_at: issuedAt,
|
|
289
|
+
verification_key: key.publicKey,
|
|
290
|
+
disclosure: "internal"
|
|
291
|
+
};
|
|
292
|
+
const hash = sha256(new TextEncoder().encode(JSON.stringify(anchorDeepSort(signed))));
|
|
293
|
+
const digest = bytesToHex(hash);
|
|
294
|
+
const signature = bytesToHex(ed25519.sign(hash, hexToBytes(key.privateKey)));
|
|
295
|
+
return { ...signed, signature, digest };
|
|
296
|
+
}
|
|
297
|
+
async function anchorRecordCheckpoint(receipts, key, opts) {
|
|
298
|
+
const checkpoint = buildRecordCheckpoint(receipts, key, opts.issuedAt);
|
|
299
|
+
const base = (opts.log || DEFAULT_LOG).replace(/\/+$/, "");
|
|
300
|
+
const out = await submitEnvelope(checkpoint, base, opts.fetchImpl);
|
|
301
|
+
if (!out.ok) return { ok: false, record_root: checkpoint.record_root, total: checkpoint.total, checkpoint, error: out.error };
|
|
302
|
+
return {
|
|
303
|
+
ok: true,
|
|
304
|
+
record_root: checkpoint.record_root,
|
|
305
|
+
total: checkpoint.total,
|
|
306
|
+
seq: out.seq,
|
|
307
|
+
entry_url: `${base}/fn/log/${out.seq}`,
|
|
308
|
+
anchored_at: out.anchored_at,
|
|
309
|
+
already_anchored: out.already_anchored,
|
|
310
|
+
checkpoint
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
async function lookupPinnedIdentity(publicKey, opts) {
|
|
314
|
+
const base = (opts && opts.log || DEFAULT_LOG).replace(/\/+$/, "");
|
|
315
|
+
const doFetch = opts && opts.fetchImpl || globalThis.fetch;
|
|
316
|
+
if (!doFetch || !/^[0-9a-f]{64}$/i.test(publicKey)) return null;
|
|
317
|
+
try {
|
|
318
|
+
const resp = await doFetch(`${base}/fn/log/keys/lookup/${publicKey.toLowerCase()}`, { headers: { accept: "application/json" } });
|
|
319
|
+
const data = await resp.json().catch(() => null);
|
|
320
|
+
if (!data || data.ok !== true) return null;
|
|
321
|
+
if (!data.found) return { found: false };
|
|
175
322
|
return {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
envelope
|
|
323
|
+
found: true,
|
|
324
|
+
name: data.name,
|
|
325
|
+
slug: data.slug,
|
|
326
|
+
kid: data.kid,
|
|
327
|
+
enrolled_at: data.enrolled_at,
|
|
328
|
+
revoked: !!(data.revoked || data.revoked_at)
|
|
183
329
|
};
|
|
184
330
|
} catch {
|
|
185
|
-
return
|
|
331
|
+
return null;
|
|
186
332
|
}
|
|
187
333
|
}
|
|
188
334
|
export {
|
|
189
335
|
ANCHOR_SCHEMA,
|
|
336
|
+
CHECKPOINT_SCHEMA,
|
|
190
337
|
CLAIM_TYPE,
|
|
191
338
|
DEFAULT_LOG,
|
|
192
339
|
anchorClaim,
|
|
340
|
+
anchorRecordCheckpoint,
|
|
193
341
|
buildAnchorEnvelope,
|
|
194
342
|
buildClaim,
|
|
343
|
+
buildRecordCheckpoint,
|
|
344
|
+
checkClaimAnchor,
|
|
195
345
|
claimDigest,
|
|
196
346
|
evaluate,
|
|
197
347
|
leafHash,
|
|
348
|
+
lookupPinnedIdentity,
|
|
198
349
|
merkleRoot,
|
|
199
350
|
receiptToLeaf,
|
|
351
|
+
verifyAnchorEnvelope,
|
|
200
352
|
verifyClaim
|
|
201
353
|
};
|