@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 +155 -5
- package/cborCodec.d.ts +13 -0
- package/gateway.d.ts +19 -1
- package/index.js +651 -507
- package/package.json +1 -1
- package/protocol.d.ts +10 -0
- package/rateLimiter.d.ts +4 -0
- package/webhookBinding.d.ts +24 -0
- package/wsBinding.d.ts +8 -0
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;
|