permaura 1.3.39 → 1.3.41

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.
Files changed (3) hide show
  1. package/bin/cli.mjs +4 -34
  2. package/bin/lib.mjs +82 -0
  3. package/package.json +1 -1
package/bin/cli.mjs CHANGED
@@ -24,6 +24,7 @@ import { spawn, spawnSync } from "node:child_process";
24
24
  import {
25
25
  platformKey,
26
26
  binaryFilename,
27
+ shutdownIfProven,
27
28
  sweepOldBinaries,
28
29
  swapBinaryIntoPlace,
29
30
  upgradePlan,
@@ -489,37 +490,6 @@ async function waitHealthz(addr, tries = 20) {
489
490
  return false;
490
491
  }
491
492
 
492
- /**
493
- * Best-effort graceful stop: `POST /v1/shutdown` (operator-authed, loopback-only) asks the gateway to
494
- * drain in-flight requests and exit on its own. Resolves true on 202. Falls through to the caller's
495
- * hard kill on any failure, e.g. a clean (non-seeded) gateway where the demo token isn't valid.
496
- */
497
- function postShutdown(addr, token) {
498
- return new Promise((resolve) => {
499
- const [host, port] = addr.split(":");
500
- const req = http.request(
501
- {
502
- method: "POST",
503
- host,
504
- port: port || 80,
505
- path: "/v1/shutdown",
506
- headers: { Authorization: `Bearer ${token}` },
507
- timeout: 1500,
508
- },
509
- (res) => {
510
- res.resume();
511
- resolve(res.statusCode === 202);
512
- },
513
- );
514
- req.on("error", () => resolve(false));
515
- req.on("timeout", () => {
516
- req.destroy();
517
- resolve(false);
518
- });
519
- req.end();
520
- });
521
- }
522
-
523
493
  async function main() {
524
494
  const { cmd, forward, noUpgrade, showVersion, showHelp } = parseArgs(process.argv.slice(2));
525
495
 
@@ -623,9 +593,9 @@ async function main() {
623
593
  // Graceful first: /v1/shutdown lets the gateway drain and exit cleanly. This matters most on
624
594
  // Windows, where child.kill() is an abrupt TerminateProcess; on Unix the foreground Ctrl-C
625
595
  // already reached the child too, so this is belt-and-braces. The child's exit handler ends the
626
- // process; the unref'd timer hard-kills if draining wedges. Without a readable token the
627
- // authenticated drain isn't available, so fall straight through to the signal.
628
- const accepted = token ? await postShutdown(ADDR, token) : false;
596
+ // process; the unref'd timer hard-kills if draining wedges. Without a readable token, or when the
597
+ // listener does not prove it holds it, nothing is sent and the signal goes to our own child.
598
+ const accepted = token ? await shutdownIfProven(ADDR, token) : false;
629
599
  if (!accepted) child.kill("SIGINT");
630
600
  setTimeout(() => {
631
601
  child.kill();
package/bin/lib.mjs CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  import crypto from "node:crypto";
6
6
  import fs from "node:fs";
7
+ import http from "node:http";
7
8
  import path from "node:path";
8
9
 
9
10
  /**
@@ -227,3 +228,84 @@ export function verifyDetachedSignatureAny(rawBytes, sigB64, publicKeyPems) {
227
228
  export function verifyDetachedSignature(rawBytes, sigB64, publicKeyPem) {
228
229
  return verifyDetachedSignatureAny(rawBytes, sigB64, [publicKeyPem]);
229
230
  }
231
+
232
+ // ── Proof that the listener holds the operator token (AG-01) ────────────────────────────────────
233
+ //
234
+ // `permaura_core::health_proof` byte for byte: a fresh challenge to `/health` with no bearer, answered with lowercase hex HMAC-SHA256 keyed with the token's UTF-8 bytes over `permaura-health-proof:<port>:<challenge>`; only then is the token sent.
235
+
236
+ export const CHALLENGE_HEADER = "x-permaura-challenge";
237
+ export const PROOF_HEADER = "x-permaura-proof";
238
+
239
+ /** A fresh challenge: 32 random bytes as 64 lowercase hex characters. */
240
+ export function newChallenge() {
241
+ return crypto.randomBytes(32).toString("hex");
242
+ }
243
+
244
+ /** The proof a gateway listening on `port` and holding `token` gives for `challenge`, or null. */
245
+ export function healthProof(token, port, challenge) {
246
+ if (!token || typeof challenge !== "string" || !/^[A-Za-z0-9_-]{16,64}$/.test(challenge)) return null;
247
+ return crypto
248
+ .createHmac("sha256", Buffer.from(token, "utf8"))
249
+ .update(`permaura-health-proof:${port}:${challenge}`, "utf8")
250
+ .digest("hex");
251
+ }
252
+
253
+ /** Whether `offered` is that proof. Constant-time, exact match only. */
254
+ export function verifyHealthProof(token, port, challenge, offered) {
255
+ const expected = healthProof(token, port, challenge);
256
+ if (!expected || typeof offered !== "string") return false;
257
+ const a = Buffer.from(expected, "utf8");
258
+ const b = Buffer.from(offered.trim(), "utf8");
259
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
260
+ }
261
+
262
+ /** `host:port` (IPv6 hosts in brackets) as the parts `http.request` takes. */
263
+ function splitAddr(addr) {
264
+ const i = addr.lastIndexOf(":");
265
+ return { host: addr.slice(0, i).replace(/^\[|\]$/g, ""), port: Number(addr.slice(i + 1)) || 80 };
266
+ }
267
+
268
+ /** One request to `addr`, resolving to the response (body drained) or null on any failure. */
269
+ function request(addr, options, timeoutMs) {
270
+ const { host, port } = splitAddr(addr);
271
+ return new Promise((resolve) => {
272
+ const req = http.request({ host, port, timeout: timeoutMs, ...options }, (res) => {
273
+ res.resume();
274
+ resolve(res);
275
+ });
276
+ req.on("error", () => resolve(null));
277
+ req.on("timeout", () => {
278
+ req.destroy();
279
+ resolve(null);
280
+ });
281
+ req.end();
282
+ });
283
+ }
284
+
285
+ /** Whether the listener on `addr` proves it holds `token`, asked with a challenge and nothing else. */
286
+ export async function proveListener(addr, token, timeoutMs = 1500) {
287
+ const challenge = newChallenge();
288
+ const res = await request(
289
+ addr,
290
+ { method: "GET", path: "/health", headers: { [CHALLENGE_HEADER]: challenge } },
291
+ timeoutMs,
292
+ );
293
+ const offered = res?.headers?.[PROOF_HEADER];
294
+ return verifyHealthProof(token, splitAddr(addr).port, challenge, typeof offered === "string" ? offered : null);
295
+ }
296
+
297
+ /**
298
+ * Best-effort graceful stop: `POST /v1/shutdown` (operator-authed, loopback-only) asks the gateway to
299
+ * drain in-flight requests and exit on its own. Resolves true on 202, and false on anything else, the
300
+ * caller then signalling its own child instead. The token goes only to a listener that has just proved
301
+ * it already holds it: whatever holds the port may not be the child at all.
302
+ */
303
+ export async function shutdownIfProven(addr, token, timeoutMs = 1500) {
304
+ if (!token || !(await proveListener(addr, token, timeoutMs))) return false;
305
+ const res = await request(
306
+ addr,
307
+ { method: "POST", path: "/v1/shutdown", headers: { Authorization: `Bearer ${token}` } },
308
+ timeoutMs,
309
+ );
310
+ return res?.statusCode === 202;
311
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "permaura",
3
- "version": "1.3.39",
3
+ "version": "1.3.41",
4
4
  "description": "Run the Permaura gateway with one command. Give your AI agents permission, not keys. `npm i -g permaura` then `permaura start --pair <code>` installs the gateway, starts it, and connects it to your permaura.com account.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",