@touchstone-cv/mcp 0.1.0 → 0.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/README.md CHANGED
@@ -72,6 +72,20 @@ self-provision one with their own Colony token (OAuth Token Exchange, RFC 8693),
72
72
 
73
73
  Only `touchstone_record` uses your signing key; the rest proxy to the remote service over your API key.
74
74
 
75
+ ### Selective field disclosure
76
+
77
+ Call `touchstone_record({ event_type, payload, selective_disclosure: true })` to commit each
78
+ payload field separately — the client computes a salted-field Merkle root locally and signs
79
+ *that* as `payload_hash`, storing the per-field salts. Later you can reveal only a subset:
80
+
81
+ ```js
82
+ touchstone_disclose({ seqs: [n], reveal: { n: ["field_a", "field_b"] } })
83
+ ```
84
+
85
+ Revealed fields ship with Merkle proofs against `payload_hash` (which your signature already
86
+ covers); withheld fields are salt-bound and their values never appear in the disclosure. The
87
+ root computation matches the server and the verifiers byte-for-byte.
88
+
75
89
  ## Verifying the log
76
90
 
77
91
  A disclosure can be checked by anyone, with no trust in Touchstone — in the
package/package.json CHANGED
@@ -1,15 +1,39 @@
1
1
  {
2
2
  "name": "@touchstone-cv/mcp",
3
- "version": "0.1.0",
4
- "description": "Local MCP server for Touchstone record agent actions into a tamper-evident, externally-anchored log. Signs locally; your Ed25519 key never leaves your machine.",
3
+ "version": "0.2.0",
4
+ "description": "Local MCP server for Touchstone \u2014 record agent actions into a tamper-evident, externally-anchored log. Signs locally; your Ed25519 key never leaves your machine.",
5
5
  "type": "module",
6
- "bin": { "touchstone-mcp": "./touchstone-mcp.mjs" },
7
- "engines": { "node": ">=18" },
6
+ "bin": {
7
+ "touchstone-mcp": "./touchstone-mcp.mjs"
8
+ },
9
+ "engines": {
10
+ "node": ">=18"
11
+ },
8
12
  "license": "Apache-2.0",
9
- "publishConfig": { "access": "public" },
10
- "repository": { "type": "git", "url": "git+https://github.com/Touchstone-CV/touchstone-mcp.git" },
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/Touchstone-CV/touchstone-mcp.git"
19
+ },
11
20
  "homepage": "https://touchstone.cv/developers",
12
- "bugs": { "url": "https://github.com/Touchstone-CV/touchstone-mcp/issues" },
13
- "keywords": ["mcp", "model-context-protocol", "touchstone", "audit-log", "tamper-evident", "ed25519", "provenance", "agent"],
14
- "files": ["touchstone-mcp.mjs", "README.md", "LICENSE"]
21
+ "bugs": {
22
+ "url": "https://github.com/Touchstone-CV/touchstone-mcp/issues"
23
+ },
24
+ "keywords": [
25
+ "mcp",
26
+ "model-context-protocol",
27
+ "touchstone",
28
+ "audit-log",
29
+ "tamper-evident",
30
+ "ed25519",
31
+ "provenance",
32
+ "agent"
33
+ ],
34
+ "files": [
35
+ "touchstone-mcp.mjs",
36
+ "README.md",
37
+ "LICENSE"
38
+ ]
15
39
  }
@@ -20,7 +20,7 @@
20
20
  // Canonical source: https://github.com/Touchstone-CV/touchstone-mcp (Apache-2.0)
21
21
  // Service: https://touchstone.cv/developers
22
22
 
23
- import { createHash, createPrivateKey, sign as edSign } from 'node:crypto';
23
+ import { createHash, createPrivateKey, sign as edSign, randomBytes } from 'node:crypto';
24
24
  import { readFileSync } from 'node:fs';
25
25
  import { createInterface } from 'node:readline';
26
26
 
@@ -49,6 +49,32 @@ function canon(v) {
49
49
  }
50
50
  const sha256hex = (s) => createHash('sha256').update(s, 'utf8').digest('hex');
51
51
 
52
+ // ── Selective disclosure: payload_hash = salted-field Merkle root (matches
53
+ // src/Service/SelectiveDisclosure.php + MerkleService + verifier.js byte-for-byte).
54
+ // Lets you reveal a subset of fields later without exposing the rest. ──
55
+ const _shaBuf = (buf) => createHash('sha256').update(buf).digest('hex');
56
+ const _hex = (h) => Buffer.from(h, 'hex');
57
+ const mLeaf = (h) => _shaBuf(Buffer.concat([Buffer.from([0]), _hex(h)]));
58
+ const mNode = (l, r) => _shaBuf(Buffer.concat([Buffer.from([1]), _hex(l), _hex(r)]));
59
+ const fieldLeaf = (k, v, salt) => sha256hex('tsd:field:v1\n' + canon([k, v, salt]));
60
+ function merkleRoot(leafHexes) {
61
+ let level = leafHexes.map(mLeaf);
62
+ while (level.length > 1) {
63
+ const next = [];
64
+ for (let i = 0; i < level.length; i += 2) next.push(i + 1 < level.length ? mNode(level[i], level[i + 1]) : level[i]);
65
+ level = next;
66
+ }
67
+ return level[0];
68
+ }
69
+ // Commit a payload object: fresh per-field salts + the Merkle root to sign as payload_hash.
70
+ function sdCommit(payload) {
71
+ const keys = Object.keys(payload).sort();
72
+ const salts = {};
73
+ for (const k of keys) salts[k] = randomBytes(16).toString('hex');
74
+ const root = merkleRoot(keys.map(k => fieldLeaf(k, payload[k], salts[k])));
75
+ return { root, salts };
76
+ }
77
+
52
78
  // ── Ed25519 detached signature (base64), key held locally ──
53
79
  function loadKey() {
54
80
  if (!CFG.seedB64) return null;
@@ -93,7 +119,21 @@ async function recordEntry(a) {
93
119
  if (eventType === '' || a.payload === undefined) throw new Error('event_type and payload are required');
94
120
  const cp = a.counterparty_sub ?? null;
95
121
  const clientTs = a.client_ts ?? null;
96
- const payloadHash = sha256hex(canon(a.payload));
122
+
123
+ // Selective-disclosure mode: commit a salted-field Merkle root locally so a later
124
+ // disclosure can reveal a subset of fields and withhold the rest, provably.
125
+ let payloadHash, sdSalts = null;
126
+ if (a.selective_disclosure) {
127
+ if (!a.payload || typeof a.payload !== 'object' || Array.isArray(a.payload)) {
128
+ throw new Error('selective_disclosure requires payload to be a JSON object');
129
+ }
130
+ const c = sdCommit(a.payload);
131
+ payloadHash = c.root;
132
+ sdSalts = c.salts;
133
+ } else {
134
+ payloadHash = sha256hex(canon(a.payload));
135
+ }
136
+
97
137
  const signedContent = canon({
98
138
  v: 1, recorder_id: CFG.recorder, event_type: eventType, actor_sub: CFG.subject,
99
139
  counterparty_sub: cp, payload_hash: payloadHash, client_ts: clientTs,
@@ -102,7 +142,10 @@ async function recordEntry(a) {
102
142
  const body = { event_type: eventType, payload_hash: payloadHash, actor_sig: actorSig };
103
143
  if (cp) body.counterparty_sub = cp;
104
144
  if (clientTs) body.client_ts = clientTs;
105
- if (a.store_payload !== false) body.body_enc = JSON.stringify(a.payload);
145
+ // SD entries must store the payload (server builds field proofs from it + salts;
146
+ // withheld values are only ever omitted at disclosure time, never the whole payload).
147
+ if (sdSalts) { body.sd_salts = sdSalts; body.body_enc = JSON.stringify(a.payload); }
148
+ else if (a.store_payload !== false) body.body_enc = JSON.stringify(a.payload);
106
149
  const res = await rest(`/api/v1/recorders/${CFG.recorder}/entries`, body);
107
150
  if (!res.ok) throw new Error('append failed (' + res.status + '): ' + (res.data.error || ''));
108
151
  return res.data; // { seq, prev_hash, entry_hash, server_ts }
@@ -115,12 +158,15 @@ const TOOLS = [
115
158
  payload: { type: 'object', description: 'arbitrary JSON describing what happened' },
116
159
  counterparty_sub: { type: 'string', description: 'optional: the other party\'s Colony sub' },
117
160
  client_ts: { type: 'string', description: 'optional ISO-8601 UTC' },
118
- store_payload: { type: 'boolean', description: 'default true; false keeps only the hash' } } } },
161
+ store_payload: { type: 'boolean', description: 'default true; false keeps only the hash' },
162
+ selective_disclosure: { type: 'boolean', description: 'commit each field separately (salted Merkle root) so a later disclosure can reveal a subset of fields and withhold the rest' } } } },
119
163
  { name: 'touchstone_recorder_info', description: 'Your recorder: id, subject, trust tier, head sequence.',
120
164
  inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
121
165
  { name: 'touchstone_disclose', description: 'Create a shareable, independently-verifiable disclosure of selected entries.',
122
166
  inputSchema: { type: 'object', required: ['seqs'], additionalProperties: false,
123
- properties: { seqs: { type: 'array', items: { type: 'integer' } } } } },
167
+ properties: {
168
+ seqs: { type: 'array', items: { type: 'integer' } },
169
+ reveal: { type: 'object', description: 'optional selective-disclosure map {seq: [field keys to reveal]}; unlisted fields stay withheld' } } } },
124
170
  { name: 'touchstone_verify', description: 'Independently verify a disclosure (token or bundle).',
125
171
  inputSchema: { type: 'object', additionalProperties: false,
126
172
  properties: { token: { type: 'string' }, bundle: { type: 'object' } } } },