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>
275 lines
14 KiB
JavaScript
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); });
|