deviant/scripts/hephaestus-setup.mjs
Local Dev 6f40b4cae5 fix(hephaestus): pass state + redirect_uri to /auth/challenge
Correcting my own script and my earlier claim that the auth-proxy dropped
OAuth `state` on the callback. Reading /root/hephaestus/auth-proxy/src on
p2pexchange:

  app.post("/challenge", async (req) => {
    const { cashaddr, state, redirect_uri } = req.body ?? {};
    const rec = createChallenge(cashaddr, state, redirect_uri);
    ...
  });

  app.post("/verify", ...) {
    ...
    if (rec.clientState) url.searchParams.set("state", rec.clientState);
    ...
  }

The endpoint takes `state` + `redirect_uri` in the body, stores them, and
echoes `state` back on the callback URL per RFC 6749 §4.1.2. This script
was posting only { cashaddr }, so clientState was empty and the callback
came back without state — Forgejo then 500'd with "state token mismatch".

The correct behaviour is exactly what the browser wallet.js does:

  body: JSON.stringify({ cashaddr, state, redirect_uri: redirectUri }),

Fix: read `state` + `redirect_uri` off the /auth/authorize URL emitted by
Forgejo's kickoff, and pass them through. Removes the client-side "re-add
state" workaround entirely.

Also drops two debug log lines that leaked into the previous commit.

No bug in the auth-proxy. No issue to file.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-31 04:13:40 +02:00

275 lines
14 KiB
JavaScript

#!/usr/bin/env node
/**
* hephaestus-setup.mjs — sign into code.silentmode.st with a chipnet wallet,
* create the `deviant` repo, upload a deploy SSH key, then hand back a plain
* git URL for pushing.
*
* Reads the auth flow's protocol from the deployed wallet.js
* (https://code.silentmode.st/auth/static/wallet.js) — we hit /auth/challenge
* and /auth/verify with the SAME shape the browser bundle uses, so if the
* frontend flow works, this does too.
*
* Wallet: reads .chipnet-keys-<letter>.json (default: d)
* SSH key: reads ~/.ssh/checkers_deploy.pub by default
*
* Usage: node --import tsx scripts/hephaestus-setup.mjs
* CHIPNET_WALLET=e SSH_KEY_PUB=~/.ssh/coinspectrum_ed25519.pub node …
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { TestNetWallet } from 'mainnet-js';
import {
secp256k1, sha256 as laSha256, utf8ToBin, encodeCashAddress,
} from '@bitauth/libauth';
const ROOT = join(import.meta.dirname, '..');
const WALLET = process.env.CHIPNET_WALLET ?? 'd';
const KEYS = join(ROOT, `.chipnet-keys-${WALLET}.json`);
const SSH_KEY_PUB = (process.env.SSH_KEY_PUB ?? '~/.ssh/checkers_deploy.pub').replace(/^~/, homedir());
const HOST = 'https://code.silentmode.st';
const REPO_NAME = 'deviant';
// ── BIP-137 "Bitcoin Signed Message" digest + signature ─────────────────────
// Mirrors magicHash() + signMessage() in the Hephaestus wallet.js verbatim.
function varInt(n) {
if (n < 0xfd) return new Uint8Array([n]);
if (n <= 0xffff) { const b = new Uint8Array(3); b[0]=0xfd; b[1]=n&0xff; b[2]=(n>>8)&0xff; return b; }
throw new Error('varInt: message too long for this tiny helper');
}
function concat(...parts) {
const total = parts.reduce((s,p)=>s+p.length, 0);
const out = new Uint8Array(total); let o = 0;
for (const p of parts) { out.set(p, o); o += p.length; }
return out;
}
function magicHash(message) {
const magic = utf8ToBin('Bitcoin Signed Message:\n');
const msg = utf8ToBin(message);
const digest1 = laSha256.hash(concat(varInt(magic.length), magic, varInt(msg.length), msg));
return laSha256.hash(digest1);
}
function signBip137(privateKey, message) {
const digest = magicHash(message);
const compact = secp256k1.signMessageHashRecoverableCompact(privateKey, digest);
if (typeof compact === 'string') throw new Error(`signMessageHashRecoverableCompact: ${compact}`);
// libauth returns { signature (64 bytes), recovery (0..3) } — repack into
// the 65-byte "header + r || s" that BIP-137 verifiers expect.
const { signature, recovery } = compact;
const sig65 = new Uint8Array(65);
sig65[0] = 27 + recovery + 4; // 4 = compressed-pubkey flag (BCH default)
sig65.set(signature, 1);
return Buffer.from(sig65).toString('base64');
}
async function main() {
console.log(`Hephaestus setup — wallet ${WALLET.toUpperCase()}, key ${SSH_KEY_PUB}`);
// Load the chipnet wallet.
const { wif } = JSON.parse(readFileSync(KEYS, 'utf8'));
const wallet = await TestNetWallet.fromWIF(wif);
// Hephaestus wallets use MAINNET cashaddrs (bitcoincash:… prefix). Rebuild
// from the same private key so the signature verifies against the address
// the auth-proxy expects.
const pkh = wallet.publicKeyHash;
const cashaddrStr = encodeCashAddress({ prefix: 'bitcoincash', type: 'p2pkh', payload: pkh });
const cashaddr = typeof cashaddrStr === 'string' ? cashaddrStr : cashaddrStr.address;
console.log(` cashaddr: ${cashaddr}`);
// Cookie jar shared across the whole flow. Forgejo stores the OAuth state +
// PKCE verifier in the SAME session cookie it sets on the very first hit —
// so that first hit must precede /auth/challenge, and the same cookie must
// still be in scope when the callback comes back.
const cookies = new Map();
const putCookies = (res) => {
for (const [k, v] of res.headers.entries()) {
if (k.toLowerCase() === 'set-cookie') {
for (const c of v.split(/,(?=[^ ])/)) {
const [nv] = c.split(';'); const eq = nv.indexOf('=');
if (eq > 0) cookies.set(nv.slice(0, eq), nv.slice(eq + 1));
}
}
}
};
const cookieHeader = () => [...cookies.entries()].map(([k,v]) => `${k}=${v}`).join('; ');
// ── 1. arm Forgejo's OAuth kickoff — this is what installs the state that
// the callback later needs to match, AND sets the Gitea session cookie
// we carry through the rest of the flow.
const kickoff = await fetch(`${HOST}/user/oauth2/hephaestus-wallet`, { redirect: 'manual' });
putCookies(kickoff);
const authorizeUrl = kickoff.headers.get('location');
if (!authorizeUrl) throw new Error(`kickoff: no redirect (HTTP ${kickoff.status})`);
console.log(` kickoff → ${new URL(authorizeUrl).pathname}`);
// PIN the Gitea session cookie now — nothing under /auth/* should be allowed
// to overwrite it. Same-origin Caddy routes both /auth/* and /* to different
// upstreams, and both may Set-Cookie on `code.silentmode.st`; if the auth-
// proxy replaces `i_like_gitea` mid-flow, the OAuth callback later loses
// Forgejo's state and returns 500.
const giteaSession = cookies.get('i_like_gitea');
const giteaCsrf = cookies.get('_csrf');
if (!giteaSession) throw new Error('kickoff did not set i_like_gitea');
const putCookiesExceptGitea = (res) => {
const gs = cookies.get('i_like_gitea'), gc = cookies.get('_csrf');
putCookies(res);
if (gs) cookies.set('i_like_gitea', gs);
if (gc) cookies.set('_csrf', gc);
};
// Auth-proxy also wants to see the OAuth kickoff (it needs the ?state=… so
// its /verify can reply with the correct callback URL). Hit /auth/authorize
// — but do NOT let its Set-Cookie clobber the pinned Gitea session.
const authorizePage = await fetch(authorizeUrl, { headers: { cookie: cookieHeader() } });
putCookiesExceptGitea(authorizePage);
// ── 2. request the challenge — auth-proxy binds it to the state above ────
// The browser's wallet.js reads `data-state` + `data-redirect-uri` off the
// login shell and posts BOTH here — that's how the auth-proxy knows the
// OIDC state to echo on the callback (RFC 6749 §4.1.2). Mirror that.
const authorizeParams = new URL(authorizeUrl).searchParams;
const kickoffState = authorizeParams.get('state') ?? '';
const kickoffRedirectUri = authorizeParams.get('redirect_uri') ?? '';
const chalRes = await fetch(`${HOST}/auth/challenge`, {
method: 'POST',
headers: { 'content-type': 'application/json', cookie: cookieHeader() },
body: JSON.stringify({ cashaddr, state: kickoffState, redirect_uri: kickoffRedirectUri }),
});
if (!chalRes.ok) throw new Error(`challenge: HTTP ${chalRes.status} — ${await chalRes.text()}`);
putCookiesExceptGitea(chalRes);
const { nonce, message } = await chalRes.json();
console.log(` nonce: ${nonce.slice(0, 16)}…`);
// ── 3. sign + verify ──────────────────────────────────────────────────────
// Use mainnet-js's built-in signer — it produces the exact BIP-137 shape
// the auth-proxy verifies against (header byte 27+recid+4, r||s, base64).
const signed = await wallet.sign(message);
const signature = signed.signature;
const verRes = await fetch(`${HOST}/auth/verify`, {
method: 'POST',
headers: { 'content-type': 'application/json', cookie: cookieHeader() },
body: JSON.stringify({ nonce, signature }),
});
if (!verRes.ok) throw new Error(`verify: HTTP ${verRes.status} — ${await verRes.text()}`);
putCookiesExceptGitea(verRes);
const { redirect } = await verRes.json();
console.log(` redirect: ${redirect.slice(0, 100)}…`);
// ── 4. follow the callback — Forgejo now finds the matching session ──────
let url = redirect;
for (let i = 0; i < 8; i++) {
const res = await fetch(url, { redirect: 'manual', headers: cookieHeader() ? { cookie: cookieHeader() } : {} });
putCookies(res);
const loc = res.headers.get('location');
console.log(` [hop ${i}] ${res.status} ${new URL(url).pathname}${loc ? ' → ' + loc : ''}`);
if (res.status < 300 || res.status >= 400) break;
if (!loc) break;
url = new URL(loc, url).toString();
}
const user = cookies.get('_gitea_incoming_v3') || cookies.get('gitea_incredible');
const csrf = cookies.get('_csrf');
const sess = cookies.get('i_like_gitea');
if (!sess) throw new Error('sign-in did not yield a Gitea session cookie');
console.log(` session cookies: ${[...cookies.keys()].join(', ')}`);
// ── 4. mint a personal access token via the web UI (session-authed form) ──
// Gitea 1.22+ refuses session cookies on /api/v1/*, so we bootstrap a
// token from the session and use that for the rest.
const tokenName = `deviant-setup-${Date.now()}`;
// The tokens page reveals a FRESH csrf value in a meta tag — reuse THAT.
const tokPage = await fetch(`${HOST}/user/settings/applications`, { headers: { cookie: cookieHeader() } });
putCookies(tokPage);
const tokPageHtml = await tokPage.text();
console.log(` tok page: HTTP ${tokPage.status}, ${tokPageHtml.length}B, has-form=${/name="_csrf"/.test(tokPageHtml)}, has-title=${/user\/settings\/applications/i.test(tokPageHtml) || /Applications/.test(tokPageHtml)}`);
const csrfMatch = tokPageHtml.match(/name="_csrf" value="([^"]+)"/);
if (!csrfMatch) {
// Peek what's actually there so debugging isn't blind.
const title = tokPageHtml.match(/<title>([^<]*)<\/title>/)?.[1];
const bodyPeek = tokPageHtml.replace(/<script[\s\S]*?<\/script>/g,'').replace(/<style[\s\S]*?<\/style>/g,'').replace(/<[^>]+>/g,' ').replace(/\s+/g,' ').trim().slice(0, 400);
throw new Error(`cannot find _csrf on /user/settings/applications (title="${title}", body="${bodyPeek}…")`);
}
const csrfLive = csrfMatch[1];
const form = new URLSearchParams({
_csrf: csrfLive, name: tokenName,
// All scopes needed: create repo, upload SSH key, push. `write:repository`
// covers repo CRUD; `write:user` covers keys.
scope: 'write:repository', scope: 'write:user',
});
form.append('scope', 'write:repository');
form.append('scope', 'write:user');
const tokRes = await fetch(`${HOST}/user/settings/applications`, {
method: 'POST',
headers: {
'content-type': 'application/x-www-form-urlencoded',
cookie: cookieHeader(),
},
redirect: 'manual',
body: form.toString(),
});
putCookies(tokRes);
let tokHtml;
if (tokRes.status >= 300 && tokRes.status < 400) {
const loc = tokRes.headers.get('location');
const nextRes = await fetch(new URL(loc, HOST).toString(), { headers: { cookie: cookieHeader() } });
putCookiesExceptGitea(nextRes);
tokHtml = await nextRes.text();
} else {
tokHtml = await tokRes.text();
}
// Gitea flashes the freshly-minted token exactly once. Historically <code>hex40</code>,
// but 1.22+ wraps it in a copy button; look for any 40-hex token in the page.
const tokenMatch = tokHtml.match(/([a-f0-9]{40})/i);
if (!tokenMatch) {
// Show any flash error the server flashed back so we see WHY.
const err = tokHtml.match(/class="[^"]*(error|flash-error)[^"]*"[^>]*>([\s\S]{0,300}?)</i)?.[2];
const title = tokHtml.match(/<title>([^<]*)<\/title>/)?.[1];
throw new Error(`token mint failed — no fresh token in the page (title="${title}", flash=${JSON.stringify(err)})`);
}
const TOKEN = tokenMatch[1];
console.log(` ✓ minted API token "${tokenName}" (${TOKEN.slice(0, 8)}…)`);
const bearer = { authorization: `token ${TOKEN}` };
// ── 5. discover the account Gitea auto-provisioned ────────────────────────
const meRes = await fetch(`${HOST}/api/v1/user`, { headers: bearer });
if (!meRes.ok) throw new Error(`GET /api/v1/user: HTTP ${meRes.status} — ${await meRes.text()}`);
const me = await meRes.json();
console.log(` account: ${me.login} (id ${me.id})`);
// ── 6. create the deviant repo (idempotent — 409 = already exists) ────────
const createRes = await fetch(`${HOST}/api/v1/user/repos`, {
method: 'POST',
headers: { 'content-type': 'application/json', ...bearer },
body: JSON.stringify({
name: REPO_NAME,
description: 'Deviant BCH wallet — CashTokens + covenants (Katalogos v1 · chipnet)',
private: false, auto_init: false, default_branch: 'main',
}),
});
if (createRes.status === 201) console.log(` ✓ created repo ${me.login}/${REPO_NAME}`);
else if (createRes.status === 409) console.log(` · repo ${me.login}/${REPO_NAME} already exists`);
else throw new Error(`create repo: HTTP ${createRes.status} — ${await createRes.text()}`);
// ── 7. upload the SSH public key (idempotent — 422 = already added) ───────
const pub = readFileSync(SSH_KEY_PUB, 'utf8').trim();
const keyRes = await fetch(`${HOST}/api/v1/user/keys`, {
method: 'POST',
headers: { 'content-type': 'application/json', ...bearer },
body: JSON.stringify({ title: `deviant-${WALLET}-${Date.now()}`, key: pub, read_only: false }),
});
if (keyRes.status === 201) console.log(` ✓ uploaded SSH key ${pub.slice(0, 40)}…`);
else if (keyRes.status === 422) console.log(` · SSH key already registered`);
else throw new Error(`upload SSH key: HTTP ${keyRes.status} — ${await keyRes.text()}`);
// ── 7. tell the caller how to point git at it ─────────────────────────────
const gitUrl = `ssh://git@code.silentmode.st:2222/${me.login}/${REPO_NAME}.git`;
console.log('');
console.log('┌─ next steps ─────────────────────────────────────────────────');
console.log(`│ git remote rename origin silentmode # bare repo → mirror`);
console.log(`│ git remote add origin ${gitUrl}`);
console.log(`│ git push -u origin main`);
console.log(`│ git remote set-url --add --push origin ${gitUrl}`);
console.log(`│ git remote set-url --add --push origin silentmode:/srv/git/deviant.git # mirror push`);
console.log('└──────────────────────────────────────────────────────────────');
}
main().catch((e) => { console.error('FAILED:', e?.message ?? e); if (e?.stack) console.error(e.stack); process.exit(1); });