← Files HypawaveARCHIVED FILE

skills/hypawave/scripts/sign_request.mjs

8.36 KB · Oct 4, 2026 · 12:32 UTC

↓ Download file

#!/usr/bin/env node
/**
 * Hypawave pubkey-signature helper (Paths 3a / 3b).
 *
 * Produces the secp256k1 signatures + headers the server requires for signed
 * requests under /api/offers/*. This is a faithful port of the reference
 * implementation in https://hypawave.com/llms.txt ("Reference implementation"
 * + "Test vector") — the server rejects compact signatures and validates a
 * specific canonical hash, so use this rather than hand-rolling.
 *
 * Dependency: @noble/secp256k1 is VENDORED at ./vendor/noble-secp256k1.mjs
 * (v1.7.1, MIT, 0-dependency, single file) so the bundle is self-contained —
 * no `npm install` and no network needed on a fresh install. Node 18+.
 *
 * Module use:
 *   import { signRequest } from "./sign_request.mjs";
 *   const { headers, body } = await signRequest({ body: {...}, privKey });
 *   await fetch(url, { method: "POST", headers, body });   // send `body` verbatim
 *
 * Body-less (GET/DELETE): pass { body: null, privKey } → header-only signature.
 *
 * CLI:
 *   node sign_request.mjs --gen                                         # generate a new identity key (save as $HYPAWAVE_PRIVKEY)
 *   echo '{"amount":1,...}' | HYPAWAVE_PRIVKEY=<privhex> node sign_request.mjs   # signed POST body
 *   HYPAWAVE_PRIVKEY=<privhex> node sign_request.mjs --bodyless                  # header-only (GET/DELETE)
 *   node sign_request.mjs --selftest                                              # verify against llms.txt vector
 *   (--key <hex> is accepted for one-off tests only — it leaks into shell history and
 *    process listings; the script warns on stderr when it is used)
 */

import * as secp from "./vendor/noble-secp256k1.mjs";
import crypto from "crypto";
import { fileURLToPath } from "url";
import { realpathSync } from "fs";

const sha256Hex = (input) => crypto.createHash("sha256").update(input).digest("hex");

// Version-tolerant DER hex: v1.x sign() returns a DER Uint8Array; v2.x returns
// a Signature with .toDERRawBytes(). Both are normalized to lowercase hex.
const toDerHex = (sig) => Buffer.from(sig.toDERRawBytes?.() ?? sig).toString("hex");

/**
 * @param {object|null} body      Request body object, or null for body-less requests.
 * @param {string} privKey        32-byte private key, hex (64 chars).
 * @param {string} [timestamp]    Override unix-seconds string (tests only).
 * @param {string} [nonce]        Override nonce hex (tests only).
 * @returns {{ headers: object, body: string|undefined, debug: object }}
 */
export async function signRequest({ body, privKey, timestamp, nonce }) {
  const privBytes = Buffer.from(privKey, "hex");
  const pubKey = Buffer.from(secp.getPublicKey(privBytes, true)).toString("hex");

  // Body-level signature: sign sha256(JSON.stringify(body)) and store it on the
  // body as `signed_payload_hash` — the body-level proof you submitted these exact
  // request bytes. This is NOT the server's canonical `terms_hash` (the 409
  // terms_changed check is computed server-side over specific offer fields).
  // llms.txt's signing section/test vector calls this value `terms_hash`, so the
  // local variable follows that naming. Body-less → skip.
  let fullBody = body;
  let termsHash = null;
  if (body) {
    termsHash = sha256Hex(JSON.stringify(body));
    const termsSig = await secp.sign(Buffer.from(termsHash, "hex"), privBytes);
    fullBody = { ...body, signed_payload_hash: termsHash, signature: toDerHex(termsSig) };
  }

  // Header-level auth signature over the exact bytes you send as the HTTP body.
  const bodyStr = fullBody ? JSON.stringify(fullBody) : "";
  const bodyHash = sha256Hex(bodyStr);
  const ts = timestamp ?? Math.floor(Date.now() / 1000).toString();
  const nce = nonce ?? crypto.randomBytes(16).toString("hex");
  const canonicalHash = sha256Hex(`${bodyHash}:${ts}:${nce}`);
  const sigBytes = await secp.sign(Buffer.from(canonicalHash, "hex"), privBytes);

  return {
    headers: {
      "Content-Type": "application/json",
      "x-pubkey": pubKey,
      "x-signature": toDerHex(sigBytes),
      "x-signed-payload-hash": bodyHash,
      "x-timestamp": ts,
      "x-nonce": nce,
    },
    body: bodyStr || undefined,
    debug: { pubKey, termsHash, bodyHash, canonicalHash },
  };
}

// ── Self-test against the published llms.txt test vector ────────────────────
async function selftest() {
  const privKey = "0000000000000000000000000000000000000000000000000000000000000001";
  const body = {
    amount: 0.01,
    pricing_type: "fiat",
    currency: "USD",
    description: "Test offer",
    payment_destination: "https://example.invalid/.well-known/lnurlp/creator",
    activation_window: "30d",
  };
  const expected = {
    pubKey: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
    termsHash: "4e5c7c24dd3c9ca598c65699a13084bd687b99365c249d4f2e3fe9363c6f1cac",
    bodyHash: "472412ee78dd3bade6df5ade1733c91b1823f097ab87c377bdb3838b89e6ff51",
    canonicalHash: "6adbcd803a3650d108a854c3e333fa8562b83e5f62937039f8f0acdeb957e3aa",
  };
  const { debug } = await signRequest({
    body,
    privKey,
    timestamp: "1779148800",
    nonce: "a1b2c3d4e5f60718293a4b5c6d7e8f90",
  });

  let ok = true;
  for (const k of Object.keys(expected)) {
    const pass = debug[k] === expected[k];
    if (!pass) ok = false;
    console.log(`  ${pass ? "✓" : "✗"} ${k}: ${debug[k]}${pass ? "" : `\n      expected ${expected[k]}`}`);
  }
  console.log(ok ? "\nself-test passed — hashing + serialization correct." : "\nself-test FAILED.");
  process.exit(ok ? 0 : 1);
}

// ── Generate a fresh secp256k1 identity key ─────────────────────────────────
// One-time seller setup: emits a new private key to save as $HYPAWAVE_PRIVKEY,
// plus its derived pubkey (your Hypawave identity). The key IS your account —
// back it up; losing it means losing control of your offers.
function genKey() {
  const privBytes = secp.utils.randomPrivateKey();
  const privKey = Buffer.from(privBytes).toString("hex");
  const pubKey = Buffer.from(secp.getPublicKey(privBytes, true)).toString("hex");
  console.error("Generated a new secp256k1 identity key. Save the private key as $HYPAWAVE_PRIVKEY and back it up — it controls your offers (separate from your payout wallet).");
  console.log(JSON.stringify({ HYPAWAVE_PRIVKEY: privKey, pubkey: pubKey }, null, 2));
}

// ── CLI ─────────────────────────────────────────────────────────────────────
async function main() {
  const argv = process.argv.slice(2);
  if (argv.includes("--selftest")) return selftest();
  if (argv.includes("--gen")) return genKey();

  const keyIdx = argv.indexOf("--key");
  if (keyIdx >= 0) {
    console.error("warning: --key puts the private key in shell history and process listings; prefer the HYPAWAVE_PRIVKEY environment variable");
  }
  const privKey = (keyIdx >= 0 ? argv[keyIdx + 1] : process.env.HYPAWAVE_PRIVKEY) || "";
  if (!/^[0-9a-fA-F]{64}$/.test(privKey)) {
    console.error("error: provide a 32-byte hex private key via --key <hex> or $HYPAWAVE_PRIVKEY");
    process.exit(2);
  }

  let body = null;
  if (!argv.includes("--bodyless")) {
    const raw = await new Promise((res) => {
      let d = "";
      process.stdin.on("data", (c) => (d += c)).on("end", () => res(d.trim()));
    });
    if (raw) {
      try {
        body = JSON.parse(raw);
      } catch {
        console.error("error: stdin is not valid JSON (use --bodyless for GET/DELETE)");
        process.exit(2);
      }
    }
  }

  let result;
  try {
    result = await signRequest({ body, privKey });
  } catch (e) {
    console.error(`error: could not sign — ${e?.message || e}. Is the private key a valid secp256k1 scalar (1 <= key < curve order)?`);
    process.exit(2);
  }
  const { headers, body: signedBody } = result;
  console.log(JSON.stringify({ headers, body: signedBody ?? null }, null, 2));
}

// Run the CLI only when executed directly — never on `import { signRequest }`.
// realpath both sides so a symlinked invocation path (e.g. /tmp → /private/tmp,
// or npm bin links) still matches the resolved module URL.
function isEntrypoint() {
  if (!process.argv[1]) return false;
  try {
    return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
  } catch {
    return false;
  }
}
if (isEntrypoint()) main();

SHA-256: 91e69215cde59b8846dd21c9582236c25b7091da96a8bc0f381836e8fc071d4c