@7h3/protocol 0.5.4 → 0.6.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/bin/7h3.js CHANGED
@@ -1,21 +1,29 @@
1
1
  #!/usr/bin/env node
2
2
  import { parseArgs } from 'node:util';
3
3
  import { createServer } from 'node:http';
4
- import { writeFileSync } from 'node:fs';
4
+ import { writeFileSync, readFileSync } from 'node:fs';
5
5
  const USAGE = `
6
6
  7h3 — Protocol CLI (wire version 7h3/0.1)
7
7
 
8
8
  Usage:
9
9
  7h3 keygen [--output <file>]
10
10
  7h3 sign --private-key <key> --sender <id> [--recipient <id>] [--payload <str>] [--ttl <ms>]
11
+ (or --private-key-file <path>, or env P7H3_PRIVATE_KEY)
11
12
  7h3 verify --public-key <key> --envelope <json>
12
13
  7h3 inspect --envelope <json>
13
14
  7h3 gateway --upstream <url> [--port <n>] [--public-key <key>] [--require ed25519|none]
14
15
  [--sign-responses] [--private-key <key>] [--sender <id>] [--metrics-port <n>]
16
+ [--allow-unverified]
17
+ (private key: --private-key-file <path>, or env GATEWAY_PRIVATE_KEY)
15
18
  7h3 keys serve [--public-key <key>] [--key-id <id>] [--port <n>]
16
19
  7h3 add --framework <name> [--sender <id>] [--output <dir>]
17
20
  7h3 help
18
21
 
22
+ Secrets:
23
+ --private-key on 'sign'/'gateway' is visible in shell history and process
24
+ listings. Prefer --private-key-file <path> or the P7H3_PRIVATE_KEY /
25
+ GATEWAY_PRIVATE_KEY environment variables.
26
+
19
27
  Commands:
20
28
  keygen Generate an Ed25519 keypair (PKCS8/SPKI, base64url-encoded)
21
29
  sign Create and sign a 7h3 envelope
@@ -30,6 +38,28 @@ function die(msg) {
30
38
  process.stderr.write(`Error: ${msg}\n`);
31
39
  process.exit(1);
32
40
  }
41
+ // A private key passed as a bare CLI argument lands in shell history and is
42
+ // visible to any other local user via `ps`/`/proc` for the life of the
43
+ // process — resolveSecretArg() prefers a file (never touches argv or the
44
+ // environment table other tools can dump) or an env var, and only falls
45
+ // back to the raw flag with an explicit warning so the risk is visible
46
+ // rather than silent.
47
+ function resolveSecretArg(flagName, flagValue, fileValue, envVarName) {
48
+ if (fileValue) {
49
+ try {
50
+ return readFileSync(fileValue, 'utf8').trim();
51
+ }
52
+ catch (err) {
53
+ die(`failed to read --${flagName}-file ${fileValue}: ${String(err)}`);
54
+ }
55
+ }
56
+ if (flagValue) {
57
+ process.stderr.write(`[7h3] Warning: --${flagName} is visible in shell history and process listings. ` +
58
+ `Prefer --${flagName}-file <path> or the ${envVarName} environment variable.\n`);
59
+ return flagValue;
60
+ }
61
+ return process.env[envVarName] || undefined;
62
+ }
33
63
  async function cmdKeygen(argv) {
34
64
  const { values } = parseArgs({
35
65
  args: argv,
@@ -65,6 +95,7 @@ async function cmdSign(argv) {
65
95
  args: argv,
66
96
  options: {
67
97
  'private-key': { type: 'string' },
98
+ 'private-key-file': { type: 'string' },
68
99
  sender: { type: 'string' },
69
100
  recipient: { type: 'string' },
70
101
  payload: { type: 'string' },
@@ -72,10 +103,10 @@ async function cmdSign(argv) {
72
103
  },
73
104
  strict: false,
74
105
  });
75
- const privateKey = values['private-key'];
106
+ const privateKey = resolveSecretArg('private-key', values['private-key'], values['private-key-file'], 'P7H3_PRIVATE_KEY');
76
107
  const sender = values['sender'];
77
108
  if (!privateKey)
78
- die('--private-key is required');
109
+ die('--private-key (or --private-key-file / P7H3_PRIVATE_KEY) is required');
79
110
  if (!sender)
80
111
  die('--sender is required');
81
112
  const { createEnvelope, signEnvelopeEd25519 } = await import('@7h3/protocol');
@@ -194,8 +225,10 @@ async function cmdGateway(argv) {
194
225
  require: { type: 'string' },
195
226
  'sign-responses': { type: 'boolean' },
196
227
  'private-key': { type: 'string' },
228
+ 'private-key-file': { type: 'string' },
197
229
  sender: { type: 'string' },
198
230
  'metrics-port': { type: 'string' },
231
+ 'allow-unverified': { type: 'boolean' },
199
232
  },
200
233
  strict: false,
201
234
  });
@@ -206,16 +239,53 @@ async function cmdGateway(argv) {
206
239
  const publicKey = values['public-key'];
207
240
  const requireMode = values['require'] ?? (publicKey ? 'ed25519' : 'none');
208
241
  const signResponses = !!(values['sign-responses']);
209
- const privateKey = values['private-key'];
242
+ const privateKey = resolveSecretArg('private-key', values['private-key'], values['private-key-file'], 'GATEWAY_PRIVATE_KEY');
210
243
  const sender = values['sender'];
211
244
  const metricsPortRaw = values['metrics-port'];
212
245
  const metricsPort = metricsPortRaw ? parseInt(metricsPortRaw, 10) : undefined;
246
+ // `7h3 gateway --upstream <url>` with no other flags used to silently start
247
+ // a fully unverified passthrough proxy — the exact opposite of what the
248
+ // command's own usage text ("a verifying HTTP proxy gateway") promises.
249
+ // Require an explicit, positive choice: either real verification material
250
+ // or an explicit acknowledgment that this instance is intentionally open.
251
+ if (requireMode === 'none' && !values['allow-unverified']) {
252
+ die('refusing to start an unverified passthrough gateway. Pass --public-key/--require to ' +
253
+ 'verify requests, or --allow-unverified to explicitly run without verification.');
254
+ }
213
255
  const { createGateway } = await import('@7h3/protocol/gateway');
214
256
  const { createStaticKeyRegistry } = await import('@7h3/protocol/key-registry');
215
257
  const keys = {};
216
258
  if (publicKey && sender)
217
259
  keys[sender] = publicKey;
218
260
  const keyRegistry = createStaticKeyRegistry(keys);
261
+ // No --replay-store flag exists (there's no CLI-friendly way to configure
262
+ // a shared backing store), but shipping with no replay protection at all
263
+ // when signatures ARE required silently drops one of the two guarantees
264
+ // this whole command exists to provide. A minimal in-memory ReplayStore is
265
+ // still only good for this single process — it won't survive a restart or
266
+ // a second instance — which is why this only applies to the local
267
+ // single-process CLI gateway, never the library default.
268
+ class InMemoryCliReplayStore {
269
+ seen = new Map();
270
+ async check(key, ttlMs) {
271
+ const nowMs = Date.now();
272
+ for (const [k, expiresAt] of this.seen) {
273
+ if (expiresAt <= nowMs)
274
+ this.seen.delete(k);
275
+ }
276
+ const existing = this.seen.get(key);
277
+ if (existing !== undefined && existing > nowMs)
278
+ return true; // replay
279
+ this.seen.set(key, nowMs + ttlMs);
280
+ return false;
281
+ }
282
+ }
283
+ let replayStore;
284
+ if (requireMode !== 'none') {
285
+ replayStore = new InMemoryCliReplayStore();
286
+ process.stderr.write('[7h3] Replay protection is in-memory for this process only — it will not survive a ' +
287
+ 'restart or a second instance. For production, use the library directly with a shared replayStore.\n');
288
+ }
219
289
  const gateway = createGateway({
220
290
  upstream: upstream,
221
291
  keyRegistry,
@@ -223,6 +293,7 @@ async function cmdGateway(argv) {
223
293
  privateKey,
224
294
  sender,
225
295
  defaultPolicy: requireMode === 'none' ? 'allow' : 'deny',
296
+ replayStore,
226
297
  });
227
298
  const server = createServer(async (req, res) => {
228
299
  const chunks = [];
@@ -252,6 +323,10 @@ async function cmdGateway(argv) {
252
323
  }
253
324
  });
254
325
  });
326
+ // Without this, a plain EADDRINUSE (an easy real-world mistake — the port
327
+ // is already in use) throws as an uncaught exception: a raw Node stack
328
+ // trace instead of this CLI's own clean `Error: ...` convention.
329
+ server.on('error', (err) => die(`gateway server: ${String(err)}`));
255
330
  server.listen(port, () => {
256
331
  process.stderr.write(`7h3 gateway listening on port ${port}\n`);
257
332
  process.stderr.write(` upstream : ${upstream}\n`);
@@ -274,6 +349,7 @@ async function cmdGateway(argv) {
274
349
  res.end('Not Found');
275
350
  }
276
351
  });
352
+ metricsServer.on('error', (err) => die(`metrics server: ${String(err)}`));
277
353
  metricsServer.listen(metricsPort, () => {
278
354
  process.stderr.write(`7h3 metrics listening on :${metricsPort}/metrics\n`);
279
355
  });
@@ -318,6 +394,7 @@ async function cmdKeysServe(argv) {
318
394
  res.end('Not Found');
319
395
  }
320
396
  });
397
+ server.on('error', (err) => die(`key server: ${String(err)}`));
321
398
  server.listen(port, () => {
322
399
  process.stderr.write(`7h3 key server listening on port ${port}\n`);
323
400
  process.stderr.write(` GET /.well-known/7h3-keys\n`);
@@ -328,8 +405,81 @@ async function cmdKeysServe(argv) {
328
405
  });
329
406
  }
330
407
  // ─── 7h3 add ───────────────────────────────────────────────────────────────────
331
- const ADD_FRAMEWORKS = ['cloudflare-worker', 'nextjs', 'express', 'hono', 'fastify', 'claude-code', 'opencode', 'codex', 'grok'];
408
+ const ADD_FRAMEWORKS = ['webmcp', 'cloudflare-worker', 'nextjs', 'express', 'hono', 'fastify', 'claude-code', 'opencode', 'codex', 'grok'];
332
409
  const FRAMEWORK_SNIPPETS = {
410
+ webmcp: (sender) => `// 7h3 — signed, capability-scoped WebMCP tools
411
+ // Install: npm install @7h3/protocol-webmcp @7h3/protocol
412
+ //
413
+ // WebMCP requires a secure context (HTTPS) and tools must be registered in the
414
+ // TOP-LEVEL page — tools inside an iframe are not discoverable by agents.
415
+
416
+ import { guard, isWebMcpSupported } from '@7h3/protocol-webmcp'
417
+ import { generateEd25519KeypairBase64Url } from '@7h3/protocol'
418
+
419
+ if (isWebMcpSupported()) {
420
+ // Per-session key: fine for signing this visitor's grants and receipts. The
421
+ // manifest is signed separately, at deploy time, by a key the browser never sees.
422
+ const { publicKey, privateKey } = await generateEd25519KeypairBase64Url()
423
+
424
+ const g = guard({
425
+ origin: ${JSON.stringify(sender)},
426
+ privateKey,
427
+ publicKey,
428
+ onConfirm: async (tool, input) =>
429
+ window.confirm(\`Allow \${tool.name}?\\n\\n\${JSON.stringify(input, null, 2)}\`),
430
+ })
431
+
432
+ // An unguarded read: no scope, so no grant is required.
433
+ await g.registerTool({
434
+ name: 'search_items',
435
+ description: 'Search the catalog',
436
+ inputSchema: {
437
+ type: 'object',
438
+ properties: { query: { type: 'string' } },
439
+ required: ['query'],
440
+ additionalProperties: false,
441
+ },
442
+ annotations: { readOnlyHint: true },
443
+ execute: async ({ query }) => searchItems(String(query)),
444
+ })
445
+
446
+ // A guarded write. \`scope\` gates it behind a capability; \`limit\` is a ceiling
447
+ // this site will never exceed, whatever a grant says.
448
+ await g.registerTool({
449
+ name: 'place_order',
450
+ description: 'Place an order for the current cart',
451
+ inputSchema: {
452
+ type: 'object',
453
+ properties: { cartId: { type: 'string' }, amountCents: { type: 'number' } },
454
+ required: ['cartId', 'amountCents'],
455
+ additionalProperties: false,
456
+ },
457
+ annotations: { destructiveHint: true },
458
+ scope: 'orders/place',
459
+ limit: { field: 'amountCents', max: 500_00 },
460
+ confirm: true,
461
+ execute: async ({ cartId }) => placeOrder(String(cartId)),
462
+ })
463
+
464
+ // Wire this to a consent control in your own UI — never grant automatically.
465
+ // The token is held page-side, so it never passes through the agent.
466
+ document.querySelector('#allow-agent')?.addEventListener('click', async () => {
467
+ await g.grant({
468
+ subject: 'browser-agent',
469
+ scopes: ['orders/place'],
470
+ caps: { amountCents: 100_00 }, // bound inside the signed token
471
+ ttlMs: 10 * 60_000, // authority lapses on its own
472
+ })
473
+ })
474
+
475
+ // Every call — allowed and refused — lands on a hash-chained signed log.
476
+ g.on((event) => {
477
+ if (event.type === 'call') {
478
+ console.log(event.receipt.outcome, event.receipt.tool, event.receipt.reason ?? '')
479
+ }
480
+ })
481
+ }
482
+ `,
333
483
  'cloudflare-worker': (sender) => `// cloudflare/src/worker.ts — 7h3 Gateway Worker
334
484
  // Install: npm install @7h3/protocol
335
485
  // See: cloudflare/DEPLOY.md for full setup
package/cborCodec.d.ts CHANGED
@@ -21,11 +21,24 @@ export declare class CborEncoder {
21
21
  private _encodeHeadBytes;
22
22
  private _concat;
23
23
  }
24
+ /**
25
+ * Maximum nesting depth accepted while decoding.
26
+ *
27
+ * RFC 8949 §10 calls this out explicitly: a decoder that recurses per nesting
28
+ * level turns a handful of attacker bytes into a stack overflow. `0x81` is
29
+ * "array of 1", so 50 KB of repeated `0x81` nests 50 000 deep and blows the
30
+ * stack — and CBOR arrives straight off the wire through the HTTP binding.
31
+ * 64 is far beyond any real envelope, which nests a handful of levels at most.
32
+ */
33
+ export declare const MAX_CBOR_DEPTH = 64;
24
34
  export declare class CborDecoder {
25
35
  private data;
26
36
  private offset;
37
+ private depth;
27
38
  decode(data: Uint8Array): unknown;
39
+ /** Depth-counting wrapper around the recursive decode body. */
28
40
  private _decode;
41
+ private _decodeItem;
29
42
  private _decodeUint;
30
43
  private _readByte;
31
44
  private _readBytes;
package/gateway.d.ts CHANGED
@@ -46,13 +46,31 @@ export type GatewayVerifyOutcome = {
46
46
  envelopeId?: string;
47
47
  } | {
48
48
  ok: false;
49
- status: 401 | 403 | 429;
49
+ status: 400 | 401 | 403 | 429;
50
50
  reason: string;
51
51
  };
52
+ /**
53
+ * Normalize a request path before it's used for both policy matching and
54
+ * upstream forwarding. Without this, a path like `/public/../admin/secret`
55
+ * matches a permissive `/public/**` policy (or no policy at all, under
56
+ * `defaultPolicy: 'allow'`) as a literal string, is forwarded unverified,
57
+ * and then gets collapsed by the URL parser inside `fetch()` on the way out
58
+ * — landing on `/admin/secret` at the upstream with zero verification ever
59
+ * having been performed against the path that's actually reached. Matching
60
+ * and forwarding must both operate on the same fully-normalized path so
61
+ * there's no gap between what was checked and what was sent.
62
+ *
63
+ * Returns null for anything that isn't a clean absolute path — including a
64
+ * `..` that would escape above the root, or percent-encoding that doesn't
65
+ * settle after a bounded number of decode passes (double-encoding is a
66
+ * classic way to smuggle a traversal past a single decode).
67
+ */
68
+ export declare function normalizeGatewayPath(rawPath: string): string | null;
52
69
  declare class Protocol7h3Gateway {
53
70
  private config;
54
71
  private rateLimiter;
55
72
  constructor(config: GatewayConfig);
73
+ private checkSenderAndRateLimit;
56
74
  verify(req: GatewayRequest): Promise<GatewayVerifyOutcome>;
57
75
  handle(req: GatewayRequest): Promise<GatewayResponse>;
58
76
  getRateLimiter(): SlidingWindowRateLimiter;