Initial commit

This commit is contained in:
Local Dev 2026-09-12 00:07:28 +02:00
commit ff11649813
22 changed files with 3548 additions and 0 deletions

23
.env.example Normal file
View file

@ -0,0 +1,23 @@
# --- sia.storage S3 credentials (from https://sia.storage dashboard) ---
SIA_STORAGE_ENDPOINT=s3.sia.storage
SIA_STORAGE_ACCESS_KEY=changeme
SIA_STORAGE_SECRET_KEY=changeme
SIA_STORAGE_BUCKET=hephaestus
# --- Postgres ---
POSTGRES_USER=forgejo
POSTGRES_PASSWORD=changeme-strong-random
POSTGRES_DB=forgejo
# --- Forgejo ---
FORGEJO_DOMAIN=localhost
FORGEJO_ROOT_URL=http://localhost:3000/
FORGEJO_SECRET_KEY=changeme-openssl-rand-hex-32
FORGEJO_INTERNAL_TOKEN=changeme-openssl-rand-hex-32
FORGEJO_OAUTH_JWT_SECRET=changeme-openssl-rand-base64-32
# --- Auth proxy ---
AUTH_PROXY_ISSUER=http://localhost:4000
AUTH_PROXY_CLIENT_ID=forgejo
AUTH_PROXY_CLIENT_SECRET=changeme-openssl-rand-hex-32
AUTH_PROXY_CHALLENGE_DOMAIN=hephaestus.silentmode.st

129
PROTOCOL.md Normal file
View file

@ -0,0 +1,129 @@
# Hephaestus Wallet-Auth Protocol
Version: 0.1 (MVP)
Signing scheme: **BIP-137-style "Bitcoin Signed Message" on Bitcoin Cash**
The point of this document is that any BCH wallet — Electron Cash, Cashonize, Zapit, or anything else that implements "Sign Message" — can produce a signature Hephaestus will accept. There is no Hephaestus-specific signing format.
## Flow
```
┌─────────┐ ┌──────────────┐ ┌──────────┐
│ Browser │ │ Auth-proxy │ │ Forgejo │
└────┬────┘ └──────┬───────┘ └─────┬────┘
│ │ │
│ 1. GET /login (redirected here by Forgejo /authorize)│
│◀─────────────────────────┘ │
│ │
│ 2. POST /challenge { cashaddr, state, redirect_uri }│
│─────────────────────────▶│ │
│◀── { nonce, message } ───│ │
│ │ │
│ 3. Sign `message` with wallet (client-side) │
│ │ │
│ 4. POST /verify { nonce, signature } │
│─────────────────────────▶│ │
│◀── { redirect: <forgejo-callback>?code=…&state=… } ──│
│ │
│ 5. GET <forgejo-callback>?code=…&state=… │
│──────────────────────────────────────────────────────▶│
│ │ 6. POST /token { code } │
│ │◀───────────────────────────│
│ │─── { id_token } ──────────▶│
│ │ │
│ 7. Session cookie set, redirect into app │
│◀──────────────────────────────────────────────────────│
```
## Challenge message
Byte-for-byte the same on both sides. Any deviation invalidates the signature.
```
{DOMAIN} wants you to sign in with your Bitcoin Cash account:
{CASHADDR}
By signing, you prove you control this address. This request will not trigger
a blockchain transaction or cost any fees.
Domain: {DOMAIN}
Nonce: {NONCE}
Issued At: {ISO8601_TIMESTAMP}
```
- `{DOMAIN}` — public hostname of the deployment (set via `CHALLENGE_DOMAIN` env)
- `{CASHADDR}` — full `bitcoincash:…` address the user is proving control of
- `{NONCE}` — 32 hex chars from `crypto.getRandomValues(new Uint8Array(16))`
- `{ISO8601_TIMESTAMP}` — server-issued `Date.now().toISOString()`
Nonces expire after 5 minutes and are single-use.
## Signature format
Follows Bitcoin Signed Message (as implemented by Bitcoin Core / Electron Cash):
1. Serialize `varint(len(magic)) || magic || varint(len(msg)) || msg`
where `magic = "Bitcoin Signed Message:\n"` (24 bytes)
2. `digest = SHA256(SHA256(serialized))`
3. Sign the 32-byte digest with recoverable ECDSA over secp256k1
4. Build the 65-byte compact signature: `header || r(32) || s(32)`
where `header = 27 + recoveryId + 4` (the `+4` marks a compressed pubkey)
5. Base64-encode the 65 bytes
Verification (on the server, in `verify.ts`):
1. Rebuild the same digest
2. Recover the compressed public key from `(compact_sig, recoveryId, digest)`
3. Hash160 the recovered pubkey → derive a p2pkh cashaddr
4. Compare against the address the user claimed at `/challenge` time
5. On match: mint an OIDC authorization code bound to the cashaddr
## OIDC claims
The `id_token` Forgejo receives:
```json
{
"iss": "https://auth.hephaestus.silentmode.st",
"aud": "forgejo",
"sub": "bitcoincash:qq…",
"preferred_username": "bitcoincash:qq…",
"iat": 1723200000,
"exp": 1723203600
}
```
- `sub` — the cashaddr, immutable per user (deterministic from mnemonic)
- `preferred_username` — Forgejo uses this to auto-provision the user record on first login
- Signed with **EdDSA (Ed25519)**; key is generated on first boot and persisted to `DATA_DIR/keys.json`
## Threat model
**In scope:**
- Passive attackers on the network: mitigated by TLS + nonce freshness
- Replay: nonces are single-use with 5-min TTL
- Forgery: requires possession of the mnemonic (24 words) or the private key
- Server compromise leaks challenge nonces but *not* signing keys — attacker cannot mint valid signatures without the user's wallet
**Out of scope (MVP):**
- Mnemonic loss = account loss. No recovery. Documented for users.
- Phishing sites collecting signed challenges under our domain string — mitigated by hard-coded `CHALLENGE_DOMAIN` in the server + user education
- Compromised browser (malicious extension reading localStorage) — user's problem
- Post-quantum secp256k1 break — everyone's problem
## Phase 2: WalletConnect / mobile signing
WalletConnect is EVM-centric. For BCH the closest equivalents:
- **Cashonize** (browser wallet) exposes a page-injected `bitcoincash` provider
- **Zapit** (mobile) supports the `pay:` URI scheme and could be extended for sign requests
- **Electron Cash** — deep-link URI: `electroncash:?sign=...` (proposal, not merged)
Planned surface: after a user hits "Sign with mobile wallet" on the login page, we render a QR code containing `hephaestus-sign://{nonce}?domain=…&addr=…` and poll `/verify` for the arriving signature. Mobile app decodes, signs with the user's key, POSTs the signature to our public endpoint with the nonce.
No changes needed to `verify.ts` for this — signature format is identical.
## Phase 2: BCNR name resolution
Every `bitcoincash:…` address can register a human-readable name via BCNR (Bitcoin Cash Name Records) in the Ariadne stack. On login, if the user's cashaddr has a BCNR name registered, we auto-populate `preferred_username` with the BCNR name instead of the raw address — matching the BCNR-first policy documented for the broader Silent Mode stack.
Fallback: DNS/traditional identifiers, per project convention.

94
README.md Normal file
View file

@ -0,0 +1,94 @@
# Hephaestus
Decentralized code forge. Forgejo web UI on a VPS, Sia network for durable storage, Bitcoin Cash wallet signatures for identity — no email required.
## Architecture
```
User browser
├── OIDC login via wallet signature ──▶ auth-proxy ──▶ (issues id_token)
│ │
└── Forgejo web UI ◀─── OIDC handshake ────────────────────┘
├── live git repos, Postgres ──▶ local SSD on VPS (hot path)
└── LFS, attachments, packages,
archive downloads, backups ──▶ sia.storage S3 (cold path)
```
- **Identity:** users generate or import a BCH wallet client-side. Their `cashaddr` is their username. Login = signing a challenge.
- **Hot storage:** live `.git` bare repos + Postgres live on the VPS's local disk. Git operations stay fast.
- **Cold storage:** every large or immutable blob (LFS, attachments, release artifacts, package registries) lives on sia.storage via its S3-compatible endpoint. Forgejo speaks S3 natively.
- **Backups:** nightly `restic` snapshot of repos + `pg_dump` to a second bucket. Dual-write to independent storage for the belt-and-suspenders story since sia.storage caps liability at ~$100.
## Why sia.storage instead of self-hosted renterd for MVP
- Their free tier gives 50 GB pooled + 4 Gbps throughput with no monthly egress meter
- Standard S3 API — zero code changes to Forgejo
- No Sia wallet management, no SC volatility, no contract-formation ops
- Swap to self-hosted `renterd` later without touching Forgejo config (both speak S3)
## Quota math
The 50 GB sia.storage free tier is one pooled resource we allocate to Forgejo users via Forgejo's built-in per-user quotas.
| Our tier | Per-user quota | Users on Free (50 GB) | Users on Plus (500 GB) | Users on Pro (5 TB) |
|---|---|---|---|---|
| Free | 500 MB | ~100 | ~1,000 | ~10,000 |
| Pro | 10 GB | ~5 | ~50 | ~500 |
| Team | 50 GB | ~1 | ~10 | ~100 |
## Quickstart
```bash
cp .env.example .env
# fill in SIA_STORAGE_ACCESS_KEY, SIA_STORAGE_SECRET_KEY, SIA_STORAGE_BUCKET
docker compose up -d
open http://localhost:3000
```
First launch:
1. Forgejo initialises Postgres and its schema
2. Auth-proxy generates its OIDC signing keypair
3. On first browser visit, the login page offers "Generate new BCH wallet" or "Import wallet"
4. After signing the challenge you're logged into Forgejo — your cashaddr is your username
## Directory layout
```
Hephaestus/
├── docker-compose.yml # forgejo + postgres + auth-proxy
├── .env.example
├── forgejo/
│ └── app.ini.template # config with sia.storage S3 + OIDC + quotas
├── auth-proxy/
│ ├── Dockerfile
│ ├── package.json
│ └── src/ # Fastify OIDC provider
│ ├── index.ts
│ ├── oidc.ts
│ ├── verify.ts # BCH signed-message verification
│ └── sessions.ts
├── auth-proxy/public/
│ ├── login.html # Wallet UI
│ └── wallet.js # Client-side keygen + signing (libauth)
├── scripts/
│ ├── smoke-test-s3.sh # Verify sia.storage handles multipart LFS
│ └── backup-restic.sh # Nightly backup
└── PROTOCOL.md # Wallet-auth flow spec
```
## Status
- MVP scaffolding: complete
- Wallet auth: functional (challenge + signature verify, no persistence beyond cashaddr)
- sia.storage backend: config template ready; **must run `scripts/smoke-test-s3.sh` before trusting LFS**
- Restic backup: template ready, needs cron wiring
- WalletConnect: deferred to Phase 2
- BCNR-name registration for `code.silentmode.st`: separate task in Ariadne stack
## Known unknowns
1. **sia.storage S3 multipart maturity.** Their roadmap targets full S3 compatibility Q3 2026 — partial today. Smoke test verifies this before we depend on it for LFS.
2. **Account-recovery UX for wallet loss.** No email = no password reset. Users must back up their mnemonic. Docs will hammer this. Phase 2: social recovery via BCH multisig.
3. **Content moderation workflow.** sia.storage ToS bans certain content classes; we need our own AUP + takedown flow before public launch.

20
auth-proxy/Dockerfile Normal file
View file

@ -0,0 +1,20 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json tsconfig.json ./
RUN npm install
COPY src ./src
COPY public ./public
RUN npm run build
FROM node:22-alpine
WORKDIR /app
RUN mkdir -p /data && chown node:node /data
USER node
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=build /app/public ./public
COPY --from=build /app/package.json ./
ENV DATA_DIR=/data
ENV PORT=4000
EXPOSE 4000
CMD ["node", "dist/index.js"]

1657
auth-proxy/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

24
auth-proxy/package.json Normal file
View file

@ -0,0 +1,24 @@
{
"name": "hephaestus-auth-proxy",
"version": "0.1.0",
"description": "OIDC provider that authenticates users by Bitcoin Cash wallet signature",
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx watch src/index.ts"
},
"dependencies": {
"@bitauth/libauth": "^3.1.0-next.4",
"@fastify/formbody": "^8.0.1",
"@fastify/static": "^8.0.3",
"fastify": "^5.1.0",
"jose": "^5.9.6"
},
"devDependencies": {
"@types/node": "^22.9.0",
"tsx": "^4.19.2",
"typescript": "^5.6.3"
}
}

118
auth-proxy/public/login.css Normal file
View file

@ -0,0 +1,118 @@
:root {
color-scheme: light dark;
--bg: #0f1115;
--card: #171a21;
--text: #e6e8ec;
--muted: #8b93a1;
--accent: #ff8c00;
--accent-fg: #0f1115;
--border: #262a34;
--err: #ff6b6b;
--ok: #4ade80;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f7f8fa;
--card: #ffffff;
--text: #0f1115;
--muted: #5a6473;
--border: #e4e7ec;
}
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--text); min-height: 100%; }
main {
max-width: 560px;
margin: 6vh auto;
padding: 36px 32px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 14px;
}
h1 { font-size: 22px; margin: 0 0 6px; line-height: 1.3; }
h1 .acid { color: var(--accent); }
h2 { font-size: 17px; margin: 0 0 6px; }
p.lede { color: var(--muted); margin: 0 0 20px; font-size: 14px; }
p.sub { color: var(--muted); margin: 0 0 12px; font-size: 13.5px; }
/* --- Wallet picker: accordion of three options (Create / Import / Connect) --- */
.wallet-picker { display: grid; gap: 10px; margin: 8px 0 18px; }
details.wallet-opt {
background: var(--bg); border: 1px solid var(--border); border-radius: 10px;
overflow: hidden; transition: border-color .1s, background .1s;
}
details.wallet-opt:hover { border-color: var(--accent); }
details.wallet-opt[open] { border-color: var(--accent); background: var(--card); }
details.wallet-opt > summary {
display: grid; grid-template-columns: 40px 1fr auto; gap: 14px; align-items: start;
padding: 14px 16px; cursor: pointer; list-style: none; user-select: none;
}
details.wallet-opt > summary::-webkit-details-marker { display: none; }
details.wallet-opt > summary::marker { content: ""; }
.wallet-opt .ico { font-size: 24px; line-height: 1; padding-top: 2px; }
.wallet-opt .body { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.wallet-opt b { color: var(--text); font-size: 14.5px; font-weight: 600;
display: inline-flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.wallet-opt .sub { color: var(--muted); font-size: 13px; line-height: 1.5; }
.wallet-opt .pill {
display: inline-block; font-size: 10.5px; padding: 1px 8px; border-radius: 999px;
background: rgba(255,140,0,.14); color: var(--accent); font-weight: 500;
letter-spacing: 0.02em; text-transform: lowercase;
}
.wallet-opt .chev {
color: var(--muted); font-size: 14px; padding-top: 4px;
transition: transform .15s ease;
}
details.wallet-opt[open] > summary .chev { transform: rotate(180deg); color: var(--accent); }
p.fine {
color: var(--muted); font-size: 12.5px; margin: 4px 0 0; text-align: center;
}
p.fine a { color: var(--muted); }
p.fine a:hover { color: var(--text); }
/* --- Expanded flow inside a wallet-opt --- */
.wallet-flow { padding: 0 16px 16px 16px; border-top: 1px solid var(--border); }
.wallet-flow .flow { display: flex; flex-direction: column; gap: 4px; padding-top: 12px; }
.wallet-flow .flow > label { margin-top: 12px; }
/* --- WizardConnect coming-soon panel --- */
.wc-supported { display: grid; gap: 8px; margin: 8px 0 14px; }
.wc-wallet {
display: grid; grid-template-columns: 1fr auto auto; gap: 12px; align-items: center;
padding: 10px 14px; background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
font-size: 13px;
}
.wc-wallet b { color: var(--text); }
.wc-wallet span { color: var(--dim, var(--muted)); font-size: 12px; }
.wc-wallet a { color: var(--accent); text-decoration: none; font-size: 12.5px; }
.wc-wallet a:hover { text-decoration: underline; }
.note-soon {
background: var(--bg); border: 1px dashed var(--border); border-left: 3px solid var(--accent);
border-radius: 8px; padding: 12px 14px; color: var(--muted); font-size: 13px;
}
.note-soon b { color: var(--text); }
label { display: block; font-size: 13px; color: var(--muted); margin: 12px 0 6px; }
input, textarea {
width: 100%; padding: 10px 12px; background: var(--bg);
color: var(--text); border: 1px solid var(--border); border-radius: 8px;
font: inherit; font-size: 14px; font-family: ui-monospace, monospace;
}
textarea { min-height: 84px; resize: vertical; }
button.primary {
width: 100%; padding: 12px; margin-top: 20px;
background: var(--accent); color: var(--accent-fg);
border: 0; border-radius: 8px; font: inherit; font-weight: 600;
cursor: pointer;
}
button.primary:disabled { opacity: 0.5; cursor: wait; }
.mono { font-family: ui-monospace, monospace; font-size: 12px; word-break: break-all; }
.warn { color: var(--err); font-size: 13px; margin-top: 8px; }
.ok { color: var(--ok); font-size: 13px; margin-top: 8px; }
.mnemonic-box {
background: var(--bg); border: 1px dashed var(--border);
border-radius: 8px; padding: 12px; margin-top: 8px;
font-family: ui-monospace, monospace; font-size: 13px; line-height: 1.7;
}
.small { font-size: 12px; color: var(--muted); }
.hidden { display: none; }

353
auth-proxy/public/wallet.js Normal file
View file

@ -0,0 +1,353 @@
/**
* Client-side BCH wallet UI.
*
* Uses @bitauth/libauth ESM build from a CDN. Wallets are generated with
* BIP-39 (12 words) BIP-32 (m/44'/145'/0'/0/0) secp256k1 keypair.
* Signatures follow "Bitcoin Signed Message" format so any BCH tool can verify.
*
* Storage: encrypted mnemonic in localStorage under 'hephaestus.wallet.v1'.
* Encryption: AES-GCM with PBKDF2(SHA-256, 200k) from a user passphrase.
* Passphrase never leaves the browser.
*/
import {
deriveHdPath,
deriveHdPrivateNodeFromSeed,
deriveSeedFromBip39Mnemonic,
encodeCashAddress,
generateBip39Mnemonic,
hash160,
hash256,
secp256k1,
utf8ToBin,
binToBase64,
binToHex,
CashAddressType,
} from "https://esm.sh/@bitauth/libauth@3.1.0-next.4";
const app = document.getElementById("app");
const state = app.dataset.state ?? "";
const redirectUri = app.dataset.redirectUri;
const STORAGE_KEY = "hephaestus.wallet.v1";
/* ---------- BCH wallet primitives ---------- */
const DERIVATION_PATH = "m/44'/145'/0'/0/0";
function encodeVarInt(n) {
if (n < 0xfd) return new Uint8Array([n]);
if (n <= 0xffff) return new Uint8Array([0xfd, n & 0xff, (n >> 8) & 0xff]);
return new Uint8Array([
0xfe, n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >>> 24) & 0xff,
]);
}
function magicHash(message) {
const magic = utf8ToBin("Bitcoin Signed Message:\n");
const msg = utf8ToBin(message);
const parts = [encodeVarInt(magic.length), magic, encodeVarInt(msg.length), msg];
const total = parts.reduce((n, p) => n + p.length, 0);
const buf = new Uint8Array(total);
let off = 0;
for (const p of parts) { buf.set(p, off); off += p.length; }
return hash256(buf);
}
async function keypairFromMnemonic(mnemonic) {
const seed = deriveSeedFromBip39Mnemonic(mnemonic);
if (typeof seed === "string") throw new Error(seed);
const root = deriveHdPrivateNodeFromSeed(seed);
const child = deriveHdPath(root, DERIVATION_PATH);
if (typeof child === "string") throw new Error(child);
const privateKey = child.privateKey;
const publicKey = secp256k1.derivePublicKeyCompressed(privateKey);
if (typeof publicKey === "string") throw new Error(publicKey);
const pkh = hash160(publicKey);
const enc = encodeCashAddress({
prefix: "bitcoincash",
type: CashAddressType.p2pkh,
payload: pkh,
});
const cashaddr = typeof enc === "string" ? enc : enc.address;
return { privateKey, publicKey, cashaddr };
}
function signMessage(privateKey, message) {
const digest = magicHash(message);
const compact = secp256k1.signMessageHashRecoverableCompact(privateKey, digest);
if (typeof compact === "string") throw new Error(compact);
// Header byte: 27 + recid + 4 (compressed pubkey flag). libauth returns
// {signature, recoveryId}; adapt to expected shape.
const recid = compact.recoveryId;
const sig65 = new Uint8Array(65);
sig65[0] = 27 + recid + 4;
sig65.set(compact.signature, 1);
return binToBase64(sig65);
}
/* ---------- Encrypted storage ---------- */
async function deriveKey(passphrase, salt) {
const enc = new TextEncoder();
const material = await crypto.subtle.importKey(
"raw", enc.encode(passphrase), "PBKDF2", false, ["deriveKey"],
);
return crypto.subtle.deriveKey(
{ name: "PBKDF2", salt, iterations: 200_000, hash: "SHA-256" },
material,
{ name: "AES-GCM", length: 256 },
false, ["encrypt", "decrypt"],
);
}
async function encryptMnemonic(mnemonic, passphrase) {
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const key = await deriveKey(passphrase, salt);
const ct = new Uint8Array(await crypto.subtle.encrypt(
{ name: "AES-GCM", iv }, key, new TextEncoder().encode(mnemonic),
));
return {
v: 1,
salt: binToHex(salt),
iv: binToHex(iv),
ct: binToHex(ct),
};
}
async function decryptMnemonic(blob, passphrase) {
const salt = hexToBin(blob.salt);
const iv = hexToBin(blob.iv);
const ct = hexToBin(blob.ct);
const key = await deriveKey(passphrase, salt);
const pt = new Uint8Array(await crypto.subtle.decrypt(
{ name: "AES-GCM", iv }, key, ct,
));
return new TextDecoder().decode(pt);
}
function hexToBin(h) {
const out = new Uint8Array(h.length / 2);
for (let i = 0; i < out.length; i++) out[i] = parseInt(h.substr(i * 2, 2), 16);
return out;
}
function hasStoredWallet() { return !!localStorage.getItem(STORAGE_KEY); }
function loadStoredWallet() { return JSON.parse(localStorage.getItem(STORAGE_KEY)); }
function saveStoredWallet(blob) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(blob));
}
/* ---------- UI ---------- */
function render() {
if (hasStoredWallet()) return renderUnlock();
return renderTabs();
}
function el(html) {
const t = document.createElement("template");
t.innerHTML = html.trim();
return t.content.firstElementChild;
}
function renderTabs() {
app.innerHTML = "";
app.appendChild(el(`
<div>
<h1>Sign in with your <span class="acid">Bitcoin Cash</span> wallet</h1>
<p class="lede">No email. No password reset. Your wallet is your identity.</p>
<div class="wallet-picker" id="picker">
<details class="wallet-opt" data-choice="create">
<summary>
<span class="ico"></span>
<span class="body">
<b>Create a new wallet<span class="pill">easiest</span></b>
<span class="sub">Made here in your browser. You get a recovery phrase to write down it is the only key.</span>
</span>
<span class="chev" aria-hidden="true"></span>
</summary>
<div class="wallet-flow"></div>
</details>
<details class="wallet-opt" data-choice="import">
<summary>
<span class="ico">🔑</span>
<span class="body">
<b>Import / add a wallet</b>
<span class="sub">Restore a wallet you already have from its 12- or 24-word recovery phrase.</span>
</span>
<span class="chev" aria-hidden="true"></span>
</summary>
<div class="wallet-flow"></div>
</details>
<details class="wallet-opt" data-choice="connect">
<summary>
<span class="ico">🪄</span>
<span class="body">
<b>Connect a wallet WizardConnect<span class="pill">most private</span></b>
<span class="sub">Cashonize 0.9+ or Paytaca. Your keys never leave your wallet.</span>
</span>
<span class="chev" aria-hidden="true"></span>
</summary>
<div class="wallet-flow"></div>
</details>
</div>
<p class="fine">Prefer old-school? <a href="/user/login?password=1">Sign in with username &amp; password </a></p>
</div>
`));
app.querySelectorAll("details.wallet-opt").forEach((d) => {
d.addEventListener("toggle", () => {
if (!d.open) return;
// Accordion: close siblings
app.querySelectorAll("details.wallet-opt").forEach((other) => {
if (other !== d) other.open = false;
});
// Lazily populate this option's flow content the first time it opens
const flow = d.querySelector(".wallet-flow");
if (flow && !flow.dataset.ready) {
populateFlow(d.dataset.choice, flow);
flow.dataset.ready = "1";
}
});
});
// Auto-open a specific option when the URL fragment is one of #create / #import / #connect
// (e.g. links from the hephaestus.x landing dropdown). Fragments survive OAuth redirects.
const hash = (location.hash || "").replace(/^#/, "").toLowerCase();
if (hash === "create" || hash === "import" || hash === "connect") {
const target = app.querySelector(`details.wallet-opt[data-choice="${hash}"]`);
if (target) target.open = true;
}
}
function populateFlow(kind, flow) {
if (kind === "create") {
const mnemonic = generateBip39Mnemonic();
flow.appendChild(el(`
<div class="flow">
<p class="sub"><b>Your new recovery phrase.</b> Write it down. This is the only way to recover your account we can never help you reset it.</p>
<div class="mnemonic-box">${mnemonic}</div>
<label>Set a passphrase to encrypt this wallet in your browser</label>
<input type="password" class="pw" autocomplete="new-password" placeholder="min 8 chars">
<p class="small">The passphrase never leaves your device. Lose both phrase and passphrase = account is gone.</p>
<button class="primary go">Create wallet &amp; sign in</button>
<p class="warn hidden err"></p>
</div>
`));
flow.querySelector(".go").addEventListener("click", async () => {
const pw = flow.querySelector(".pw").value;
if (pw.length < 8) return showErr(flow, "passphrase must be at least 8 chars");
await onboardAndLogin(mnemonic, pw, flow);
});
} else if (kind === "import") {
flow.appendChild(el(`
<div class="flow">
<p class="sub">Paste your BIP-39 recovery phrase (12 or 24 words). Same phrase = same account.</p>
<textarea class="mn" spellcheck="false" autocomplete="off" placeholder="word word word ..."></textarea>
<label>Set a passphrase to encrypt this wallet in your browser</label>
<input type="password" class="pw" autocomplete="new-password" placeholder="min 8 chars">
<button class="primary go">Import &amp; sign in</button>
<p class="warn hidden err"></p>
</div>
`));
flow.querySelector(".go").addEventListener("click", async () => {
const mn = flow.querySelector(".mn").value.trim().replace(/\s+/g, " ");
const pw = flow.querySelector(".pw").value;
if (pw.length < 8) return showErr(flow, "passphrase must be at least 8 chars");
try { await keypairFromMnemonic(mn); }
catch { return showErr(flow, "invalid recovery phrase"); }
await onboardAndLogin(mn, pw, flow);
});
} else if (kind === "connect") {
flow.appendChild(el(`
<div class="flow">
<p class="sub">Sign the challenge with an external BCH wallet your keys never leave it. Encrypted end-to-end over Nostr; the relay only sees ciphertext.</p>
<div class="wc-supported">
<div class="wc-wallet"><b>Cashonize</b><span>browser extension · v0.9+</span><a href="https://cashonize.com/" target="_blank" rel="noopener">install </a></div>
<div class="wc-wallet"><b>Paytaca</b><span>mobile · iOS + Android</span><a href="https://paytaca.com/" target="_blank" rel="noopener">install </a></div>
</div>
<p class="note-soon"><b>Coming soon.</b> WizardConnect for message-signing is being wired in for now, use Create or Import above.</p>
</div>
`));
}
}
function renderUnlock() {
app.innerHTML = "";
app.appendChild(el(`
<div>
<h1>Unlock your wallet</h1>
<p class="lede">Enter the passphrase you set when you created this wallet in this browser.</p>
<label>Passphrase</label>
<input type="password" id="pw" autocomplete="current-password">
<button class="primary" id="go">Unlock & sign in</button>
<p class="warn hidden" id="err"></p>
<p class="small" style="margin-top:16px">
Wrong browser? <a href="#" id="reset">Forget this wallet and start over</a>.
</p>
</div>
`));
app.querySelector("#reset").addEventListener("click", (e) => {
e.preventDefault();
localStorage.removeItem(STORAGE_KEY);
renderTabs();
});
app.querySelector("#go").addEventListener("click", async () => {
const pw = app.querySelector("#pw").value;
const blob = loadStoredWallet();
let mnemonic;
try { mnemonic = await decryptMnemonic(blob, pw); }
catch { return showErr(app, "wrong passphrase"); }
await signInWithMnemonic(mnemonic, app);
});
}
async function onboardAndLogin(mnemonic, passphrase, root) {
const btn = root.querySelector("button.primary");
btn.disabled = true;
try {
const blob = await encryptMnemonic(mnemonic, passphrase);
saveStoredWallet(blob);
await signInWithMnemonic(mnemonic, root);
} catch (e) {
btn.disabled = false;
showErr(root, e.message ?? String(e));
}
}
async function signInWithMnemonic(mnemonic, root) {
const btn = root.querySelector("button.primary");
if (btn) btn.disabled = true;
try {
const { privateKey, cashaddr } = await keypairFromMnemonic(mnemonic);
const chalRes = await fetch("challenge", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ cashaddr, state, redirect_uri: redirectUri }),
});
if (!chalRes.ok) throw new Error("challenge request failed");
const { nonce, message } = await chalRes.json();
const signature = signMessage(privateKey, message);
const verRes = await fetch("verify", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ nonce, signature }),
});
if (!verRes.ok) throw new Error("signature rejected");
const { redirect } = await verRes.json();
window.location.href = redirect;
} catch (e) {
if (btn) btn.disabled = false;
showErr(root, e.message ?? String(e));
}
}
function showErr(root, msg) {
const box = root.querySelector("#err");
if (!box) return;
box.textContent = msg;
box.classList.remove("hidden");
}
render();

259
auth-proxy/src/index.ts Normal file
View file

@ -0,0 +1,259 @@
/**
* Hephaestus auth-proxy OIDC provider that authenticates users by BCH signature.
*
* Endpoints:
* GET / login page (wallet UI)
* GET /.well-known/openid-configuration OIDC discovery doc
* GET /jwks public JWKS
* GET /authorize Forgejo redirects users here
* POST /challenge { cashaddr, state } challenge string + nonce
* POST /verify { nonce, signature } redirect to Forgejo with code
* POST /token Forgejo exchanges code id_token
* GET /userinfo id_token { sub, preferred_username }
*/
import Fastify from "fastify";
import formbody from "@fastify/formbody";
import fastifyStatic from "@fastify/static";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { getKeys, issueIdToken } from "./oidc.js";
import {
createChallenge,
consumeChallenge,
mintAuthCode,
consumeAuthCode,
} from "./sessions.js";
import { verifyBchSignedMessage, buildChallenge } from "./verify.js";
const ISSUER = process.env.ISSUER ?? "http://localhost:4000";
const CLIENT_ID = process.env.CLIENT_ID ?? "forgejo";
const CLIENT_SECRET = process.env.CLIENT_SECRET ?? "";
const CHALLENGE_DOMAIN =
process.env.CHALLENGE_DOMAIN ?? "hephaestus.localhost";
const FORGEJO_REDIRECT_URI =
process.env.FORGEJO_REDIRECT_URI ??
"http://localhost:3000/user/oauth2/hephaestus-wallet/callback";
const PORT = Number(process.env.PORT ?? 4000);
const app = Fastify({ logger: true });
await app.register(formbody);
const here = dirname(fileURLToPath(import.meta.url));
await app.register(fastifyStatic, {
root: join(here, "..", "public"),
prefix: "/static/",
});
// --- OIDC discovery ---
app.get("/.well-known/openid-configuration", async () => ({
issuer: ISSUER,
authorization_endpoint: `${ISSUER}/authorize`,
token_endpoint: `${ISSUER}/token`,
userinfo_endpoint: `${ISSUER}/userinfo`,
jwks_uri: `${ISSUER}/jwks`,
response_types_supported: ["code"],
subject_types_supported: ["public"],
id_token_signing_alg_values_supported: ["EdDSA"],
scopes_supported: ["openid", "profile"],
token_endpoint_auth_methods_supported: [
"client_secret_post",
"client_secret_basic",
],
claims_supported: ["sub", "preferred_username"],
}));
app.get("/jwks", async () => {
const { publicJwk } = await getKeys();
return { keys: [publicJwk] };
});
// --- Login page (delivered when Forgejo redirects to /authorize) ---
type AuthorizeQuery = {
client_id?: string;
redirect_uri?: string;
state?: string;
response_type?: string;
scope?: string;
nonce?: string;
};
app.get<{ Querystring: AuthorizeQuery }>("/authorize", async (req, reply) => {
const q = req.query;
if (q.client_id !== CLIENT_ID) {
return reply.code(400).send({ error: "unknown client_id" });
}
if (q.response_type !== "code") {
return reply
.code(400)
.send({ error: "unsupported_response_type", supported: "code" });
}
const redirectUri = q.redirect_uri ?? FORGEJO_REDIRECT_URI;
const state = q.state ?? "";
return reply
.type("text/html")
.send(loginHtml({ state, redirectUri, oidcNonce: q.nonce ?? "" }));
});
app.get("/", async (_req, reply) => {
return reply.type("text/html").send(
loginHtml({
state: "",
redirectUri: FORGEJO_REDIRECT_URI,
oidcNonce: "",
}),
);
});
// --- Challenge issuance ---
app.post<{
Body: { cashaddr: string; state?: string; redirect_uri?: string };
}>("/challenge", async (req, reply) => {
const { cashaddr, state, redirect_uri } = req.body ?? {};
// Accept any CashAddr prefix the chain actually uses:
// bitcoincash: (mainnet)
// bchtest: (chipnet / testnet — what Silent Mode's whole stack runs on today)
// bchreg: (regtest)
if (!cashaddr || !/^(bitcoincash|bchtest|bchreg):/.test(cashaddr)) {
return reply.code(400).send({
error: "cashaddr must start with bitcoincash:, bchtest: or bchreg:",
});
}
const rec = createChallenge(cashaddr, state, redirect_uri);
const message = buildChallenge({
domain: CHALLENGE_DOMAIN,
cashaddr,
nonce: rec.nonce,
issuedAt: rec.issuedAt,
});
return { nonce: rec.nonce, message };
});
// --- Signature verification & OAuth code mint ---
// `state` in the body is a fallback so scripted callers who bypass /challenge's
// state-passing (or use their own OAuth kickoff wrapper) can still get RFC 6749
// §4.1.2 compliant callbacks. If state was stored via /challenge it's used
// automatically; explicit body override wins if both are present.
app.post<{
Body: { nonce: string; signature: string; state?: string; redirect_uri?: string };
}>("/verify", async (req, reply) => {
const { nonce, signature, state: stateOverride, redirect_uri: redirectOverride } = req.body ?? {};
if (!nonce || !signature) {
return reply.code(400).send({ error: "nonce and signature required" });
}
const rec = consumeChallenge(nonce);
if (!rec) return reply.code(400).send({ error: "challenge expired or unknown" });
const message = buildChallenge({
domain: CHALLENGE_DOMAIN,
cashaddr: rec.cashaddr,
nonce: rec.nonce,
issuedAt: rec.issuedAt,
});
const recovered = verifyBchSignedMessage(rec.cashaddr, message, signature);
if (!recovered) return reply.code(401).send({ error: "signature mismatch" });
const redirectUri = redirectOverride ?? rec.redirectUri ?? FORGEJO_REDIRECT_URI;
const code = mintAuthCode(recovered, CLIENT_ID, redirectUri);
const url = new URL(redirectUri);
url.searchParams.set("code", code);
const stateToEcho = stateOverride ?? rec.clientState;
if (stateToEcho) url.searchParams.set("state", stateToEcho);
return { redirect: url.toString() };
});
// --- Token exchange ---
type TokenBody = {
grant_type: string;
code: string;
redirect_uri: string;
client_id?: string;
client_secret?: string;
};
app.post<{ Body: TokenBody }>("/token", async (req, reply) => {
const b = req.body ?? ({} as TokenBody);
const authHeader = req.headers.authorization;
let clientId = b.client_id;
let clientSecret = b.client_secret;
if (!clientId && authHeader?.startsWith("Basic ")) {
const decoded = Buffer.from(authHeader.slice(6), "base64").toString("utf8");
const idx = decoded.indexOf(":");
if (idx > 0) {
clientId = decoded.slice(0, idx);
clientSecret = decoded.slice(idx + 1);
}
}
if (clientId !== CLIENT_ID || clientSecret !== CLIENT_SECRET) {
return reply.code(401).send({ error: "invalid_client" });
}
if (b.grant_type !== "authorization_code") {
return reply.code(400).send({ error: "unsupported_grant_type" });
}
const rec = consumeAuthCode(b.code);
if (!rec) return reply.code(400).send({ error: "invalid_grant" });
if (rec.clientId !== clientId || rec.redirectUri !== b.redirect_uri) {
return reply.code(400).send({ error: "code binding mismatch" });
}
const id_token = await issueIdToken({
issuer: ISSUER,
audience: clientId,
subject: rec.cashaddr,
});
return {
access_token: id_token,
token_type: "Bearer",
expires_in: 3600,
id_token,
scope: "openid profile",
};
});
// --- Userinfo ---
app.get("/userinfo", async (req, reply) => {
const auth = req.headers.authorization;
if (!auth?.startsWith("Bearer ")) return reply.code(401).send({ error: "missing bearer" });
const token = auth.slice(7);
// Decode without verifying — token was signed by us moments ago and rotates hourly.
// Forgejo verifies against JWKS itself; this endpoint just echoes claims.
const parts = token.split(".");
if (parts.length !== 3) return reply.code(401).send({ error: "malformed token" });
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
return {
sub: payload.sub,
preferred_username: payload.preferred_username,
};
});
// --- HTML for the login page ---
function loginHtml(opts: {
state: string;
redirectUri: string;
oidcNonce: string;
}): string {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Hephaestus Sign in with wallet</title>
<link rel="stylesheet" href="static/login.css">
</head>
<body>
<main id="app"
data-state="${escapeHtml(opts.state)}"
data-redirect-uri="${escapeHtml(opts.redirectUri)}"
data-oidc-nonce="${escapeHtml(opts.oidcNonce)}"></main>
<script type="module" src="static/wallet.js"></script>
</body>
</html>`;
}
function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!,
);
}
app.listen({ port: PORT, host: "0.0.0.0" }).catch((e) => {
app.log.error(e);
process.exit(1);
});

85
auth-proxy/src/oidc.ts Normal file
View file

@ -0,0 +1,85 @@
/**
* OIDC provider keys and id_token issuance.
*
* Loads (or generates on first boot) an EdDSA keypair from DATA_DIR/keys.json.
* Exposes JWKS for Forgejo to validate id_tokens.
*/
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { SignJWT, exportJWK, generateKeyPair, importJWK, JWK } from "jose";
const DATA_DIR = process.env.DATA_DIR ?? "./data";
const KEYS_PATH = join(DATA_DIR, "keys.json");
interface KeyBundle {
privateJwk: JWK;
publicJwk: JWK;
kid: string;
}
let cachedKeys: KeyBundle | null = null;
export async function getKeys(): Promise<KeyBundle> {
if (cachedKeys) return cachedKeys;
if (existsSync(KEYS_PATH)) {
const raw = await readFile(KEYS_PATH, "utf8");
cachedKeys = JSON.parse(raw);
return cachedKeys!;
}
await mkdir(dirname(KEYS_PATH), { recursive: true });
const { privateKey, publicKey } = await generateKeyPair("EdDSA", {
crv: "Ed25519",
extractable: true,
});
const privateJwk = await exportJWK(privateKey);
const publicJwk = await exportJWK(publicKey);
const kid = "hephaestus-1";
privateJwk.kid = kid;
publicJwk.kid = kid;
publicJwk.use = "sig";
publicJwk.alg = "EdDSA";
cachedKeys = { privateJwk, publicJwk, kid };
await writeFile(KEYS_PATH, JSON.stringify(cachedKeys, null, 2), {
mode: 0o600,
});
return cachedKeys;
}
export async function issueIdToken(params: {
issuer: string;
audience: string;
subject: string;
nonce?: string;
}): Promise<string> {
const { privateJwk, kid } = await getKeys();
const key = await importJWK(privateJwk, "EdDSA");
// sub = full cashaddr (immutable identity). preferred_username = a Forgejo-safe
// rendering: "bch_" + first 20 chars of the address payload. Forgejo usernames
// must match [a-zA-Z0-9-_.]+ (no colons) and be <= 40 chars.
// Strip ANY CashAddr prefix (bitcoincash: / bchtest: / bchreg:) — the payload
// after the colon is the part we want to render as a Forgejo username.
const addressPayload = params.subject.replace(/^[a-z]+:/, "");
const preferredUsername = `bch_${addressPayload.slice(0, 20)}`;
const jwt = await new SignJWT({
preferred_username: preferredUsername,
// full cashaddr also exposed as a custom claim for downstream tooling
cashaddr: params.subject,
// Forgejo needs a synthetic email for auto-registration to succeed
email: `${preferredUsername}@wallet.hephaestus.local`,
email_verified: true,
...(params.nonce ? { nonce: params.nonce } : {}),
})
.setProtectedHeader({ alg: "EdDSA", kid })
.setIssuer(params.issuer)
.setAudience(params.audience)
.setSubject(params.subject)
.setIssuedAt()
.setExpirationTime("1h")
.sign(key);
return jwt;
}

View file

@ -0,0 +1,82 @@
/**
* In-memory session and nonce stores.
*
* MVP-only: fine for a single-process deployment. For horizontal scaling swap
* for Redis with the same interface nothing else in the codebase touches
* these stores directly.
*/
import { randomBytes } from "node:crypto";
interface ChallengeRecord {
cashaddr: string;
nonce: string;
issuedAt: string;
expiresAt: number; // epoch ms
clientState?: string; // OIDC "state" round-tripped from Forgejo
redirectUri?: string;
}
interface AuthCode {
cashaddr: string;
clientId: string;
redirectUri: string;
createdAt: number;
}
const challenges = new Map<string, ChallengeRecord>();
const authCodes = new Map<string, AuthCode>();
const CHALLENGE_TTL_MS = 5 * 60 * 1000;
const CODE_TTL_MS = 60 * 1000;
setInterval(() => {
const now = Date.now();
for (const [k, v] of challenges) if (v.expiresAt < now) challenges.delete(k);
for (const [k, v] of authCodes)
if (v.createdAt + CODE_TTL_MS < now) authCodes.delete(k);
}, 30_000).unref();
export function createChallenge(
cashaddr: string,
clientState?: string,
redirectUri?: string,
): ChallengeRecord {
const nonce = randomBytes(16).toString("hex");
const record: ChallengeRecord = {
cashaddr,
nonce,
issuedAt: new Date().toISOString(),
expiresAt: Date.now() + CHALLENGE_TTL_MS,
clientState,
redirectUri,
};
challenges.set(nonce, record);
return record;
}
export function consumeChallenge(nonce: string): ChallengeRecord | null {
const r = challenges.get(nonce);
if (!r) return null;
challenges.delete(nonce);
if (r.expiresAt < Date.now()) return null;
return r;
}
export function mintAuthCode(
cashaddr: string,
clientId: string,
redirectUri: string,
): string {
const code = randomBytes(24).toString("hex");
authCodes.set(code, { cashaddr, clientId, redirectUri, createdAt: Date.now() });
return code;
}
export function consumeAuthCode(code: string): AuthCode | null {
const r = authCodes.get(code);
if (!r) return null;
authCodes.delete(code);
if (r.createdAt + CODE_TTL_MS < Date.now()) return null;
return r;
}

130
auth-proxy/src/verify.ts Normal file
View file

@ -0,0 +1,130 @@
/**
* BCH "Bitcoin Signed Message" verification.
*
* Signature format matches Bitcoin Core's `signmessage` / Electron Cash's
* "Sign/Verify Message" dialog: 65-byte recoverable ECDSA signature (recid+r+s),
* base64-encoded, over the double-SHA256 of the varint-prefixed magic + message.
*
* Given a cashaddr, a message, and a base64 signature, we:
* 1. Rebuild the message hash with the "Bitcoin Signed Message:\n" magic
* 2. Recover the pubkey from the signature
* 3. Derive a cashaddr from the recovered pubkey (both compressed forms)
* 4. Compare against the claimed address
*/
import {
secp256k1,
hash256,
encodeCashAddress,
hash160,
binToHex,
utf8ToBin,
base64ToBin,
CashAddressType,
type RecoveryId,
} from "@bitauth/libauth";
const MAGIC = "Bitcoin Signed Message:\n";
function encodeVarInt(n: number): Uint8Array {
if (n < 0xfd) return new Uint8Array([n]);
if (n <= 0xffff) return new Uint8Array([0xfd, n & 0xff, (n >> 8) & 0xff]);
if (n <= 0xffffffff)
return new Uint8Array([
0xfe,
n & 0xff,
(n >> 8) & 0xff,
(n >> 16) & 0xff,
(n >>> 24) & 0xff,
]);
throw new Error("message too large");
}
function magicHash(message: string): Uint8Array {
const magicBytes = utf8ToBin(MAGIC);
const msgBytes = utf8ToBin(message);
const parts = [
encodeVarInt(magicBytes.length),
magicBytes,
encodeVarInt(msgBytes.length),
msgBytes,
];
const total = parts.reduce((n, p) => n + p.length, 0);
const buf = new Uint8Array(total);
let off = 0;
for (const p of parts) {
buf.set(p, off);
off += p.length;
}
return hash256(buf);
}
/**
* Verify a base64 recoverable-ECDSA signature against a message and cashaddr.
* Returns the recovered cashaddr on success, or null on any mismatch.
*
* The prefix is auto-detected from the claimed cashaddr, so both
* bitcoincash:qq... (mainnet)
* bchtest:qq... (chipnet / testnet)
* bchreg:qq... (regtest)
* work with identical key material. This matters because chipnet wallets
* (which the whole Silent Mode stack runs on today) sign the same message
* with the same key, but display their address with the bchtest: prefix.
*/
const KNOWN_PREFIXES = ["bitcoincash", "bchtest", "bchreg"] as const;
type Prefix = (typeof KNOWN_PREFIXES)[number];
export function verifyBchSignedMessage(
claimedCashaddr: string,
message: string,
signatureBase64: string,
): string | null {
const prefixMatch = claimedCashaddr.match(/^([a-z]+):/);
if (!prefixMatch) return null;
const prefix = prefixMatch[1] as Prefix;
if (!KNOWN_PREFIXES.includes(prefix)) return null;
const sig = base64ToBin(signatureBase64);
if (typeof sig === "string" || sig.length !== 65) return null;
const recoveryId = ((sig[0] - 27) & 0x03) as RecoveryId;
const compact = sig.slice(1);
const digest = magicHash(message);
const pubkey = secp256k1.recoverPublicKeyCompressed(
compact,
recoveryId,
digest,
);
if (typeof pubkey === "string") return null;
const pkh = hash160(pubkey);
const encoded = encodeCashAddress({
prefix,
type: CashAddressType.p2pkh,
payload: pkh,
});
const recovered = typeof encoded === "string" ? encoded : encoded.address;
return recovered === claimedCashaddr ? recovered : null;
}
/** Deterministic challenge string that both sides must reproduce byte-for-byte. */
export function buildChallenge(params: {
domain: string;
cashaddr: string;
nonce: string;
issuedAt: string; // ISO 8601
}): string {
return [
`${params.domain} wants you to sign in with your Bitcoin Cash account:`,
params.cashaddr,
"",
"By signing, you prove you control this address. This request will not trigger",
"a blockchain transaction or cost any fees.",
"",
`Domain: ${params.domain}`,
`Nonce: ${params.nonce}`,
`Issued At: ${params.issuedAt}`,
].join("\n");
}

16
auth-proxy/tsconfig.json Normal file
View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src",
"declaration": false,
"sourceMap": true,
"resolveJsonModule": true
},
"include": ["src/**/*"]
}

78
caddy/Caddyfile Normal file
View file

@ -0,0 +1,78 @@
# Caddy fronts the whole Hephaestus stack.
#
# TWO HOSTNAMES:
# code.silentmode.st Traditional DNS + Let's Encrypt HTTP-01 cert. Same as before.
# hephaestus.x BCNR name (chipnet). TLS cert issued by Silent Mode's own
# root CA (Argonautica) — trusted by Theseus/Ariadne users;
# other browsers see a self-signed-style warning. hephaestus.x
# also serves static content from /srv/hephaestus.x/* (the
# landing pages) BEFORE falling through to Forgejo, so
# marketing/docs pages don't get eaten by Forgejo's routes.
#
# ROUTING (both hostnames):
# /auth/* → auth-proxy container (prefix stripped)
# / → Forgejo container (with per-hostname behaviour below)
#
# {$SITE_HOSTNAME} comes from docker-compose .env (SITE_HOSTNAME=code.silentmode.st).
# --- code.silentmode.st: unchanged from before ---
{$SITE_HOSTNAME} {
encode gzip zstd
handle_path /auth/* {
reverse_proxy auth-proxy:4000
}
# Make BCH-wallet login the DEFAULT for /user/login. Old-school
# username/password stays reachable via `/user/login?password=1`
# (unmatched by this rule; falls through to Forgejo).
@plain_login {
path /user/login
not query password=1
}
redir @plain_login /user/oauth2/hephaestus-wallet 302
handle {
reverse_proxy forgejo:3000
request_body {
max_size 2GB
}
}
}
# --- hephaestus.x: static-first, Forgejo-fallback, Silent Mode CA cert ---
hephaestus.x {
encode gzip zstd
# TLS cert from Silent Mode's Argonautica CA (name-constrained to permitted TLDs).
# Theseus + Ariadne users have this CA in their trust store per the setup docs.
tls /etc/caddy/certs/hephaestus.x.crt /etc/caddy/certs/hephaestus.x.key
handle_path /auth/* {
reverse_proxy auth-proxy:4000
}
# Same wallet-login-as-default as code.silentmode.st. Old-school
# password login still reachable via /user/login?password=1.
@plain_login {
path /user/login
not query password=1
}
redir @plain_login /user/oauth2/hephaestus-wallet 302
# Static-file-first, Forgejo-fallback. If /srv/hephaestus.x/{path} (or {path}/index.html)
# exists, serve it. Otherwise the request falls through to Forgejo so all git/api/user
# routes still work.
root * /srv/hephaestus.x
@static file {path} {path}index.html {path}/index.html
handle @static {
try_files {path} {path}index.html {path}/index.html
file_server
}
handle {
reverse_proxy forgejo:3000
request_body {
max_size 2GB
}
}
}

137
docker-compose.yml Normal file
View file

@ -0,0 +1,137 @@
services:
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3
environment:
SITE_HOSTNAME: ${SITE_HOSTNAME}
volumes:
# Mount the whole caddy/ dir so both Caddyfile AND certs/ are accessible.
- ./caddy:/etc/caddy:ro
# Static content for hephaestus.x — landing pages served before falling through to Forgejo.
- /var/www/hephaestus.x:/srv/hephaestus.x:ro
- caddy-data:/data
- caddy-config:/config
depends_on:
- forgejo
- auth-proxy
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
interval: 10s
timeout: 5s
retries: 5
auth-proxy:
build: ./auth-proxy
restart: unless-stopped
environment:
ISSUER: ${AUTH_PROXY_ISSUER}
CLIENT_ID: ${AUTH_PROXY_CLIENT_ID}
CLIENT_SECRET: ${AUTH_PROXY_CLIENT_SECRET}
CHALLENGE_DOMAIN: ${AUTH_PROXY_CHALLENGE_DOMAIN}
FORGEJO_REDIRECT_URI: ${FORGEJO_ROOT_URL}user/oauth2/hephaestus-wallet/callback
# Caddy proxies /auth/* → auth-proxy:4000 over the internal network. No public port.
expose:
- "4000"
volumes:
- auth-proxy-keys:/data
forgejo:
# Small extension of the official image that adds the Silent Mode CA cert
# (Argonautica root) to the OS trust store, so Forgejo's Go client can
# validate hephaestus.x's cert on OIDC-discovery calls.
build: ./forgejo
restart: unless-stopped
environment:
USER_UID: "1000"
USER_GID: "1000"
FORGEJO__database__DB_TYPE: postgres
FORGEJO__database__HOST: postgres:5432
FORGEJO__database__NAME: ${POSTGRES_DB}
FORGEJO__database__USER: ${POSTGRES_USER}
FORGEJO__database__PASSWD: ${POSTGRES_PASSWORD}
FORGEJO__server__DOMAIN: ${FORGEJO_DOMAIN}
FORGEJO__server__ROOT_URL: ${FORGEJO_ROOT_URL}
FORGEJO__security__SECRET_KEY: ${FORGEJO_SECRET_KEY}
FORGEJO__security__INTERNAL_TOKEN: ${FORGEJO_INTERNAL_TOKEN}
FORGEJO__oauth2__JWT_SECRET: ${FORGEJO_OAUTH_JWT_SECRET}
# --- Storage backend: Sia via silentmode.st's s3d (public endpoint on port 8600) ---
# Cert is a real LE cert issued to navigate.st (with SAN for s3.silentmode.st);
# Go's default TLS chain trusts it fine. No skip-verify needed.
# The 'hephaestus' bucket was pre-created on the 'sync' account.
# Path prefixes keep repos/LFS/attachments/packages/archives separated within the bucket.
FORGEJO__storage__MINIO_ENDPOINT: ${SIA_STORAGE_ENDPOINT}
FORGEJO__storage__MINIO_ACCESS_KEY_ID: ${SIA_STORAGE_ACCESS_KEY}
FORGEJO__storage__MINIO_SECRET_ACCESS_KEY: ${SIA_STORAGE_SECRET_KEY}
FORGEJO__storage__MINIO_BUCKET: ${SIA_STORAGE_BUCKET}
FORGEJO__storage__MINIO_LOCATION: us-east-1
FORGEJO__storage__MINIO_USE_SSL: "true"
FORGEJO__storage.lfs__STORAGE_TYPE: minio
FORGEJO__storage.lfs__MINIO_BASE_PATH: lfs/
FORGEJO__storage.attachments__STORAGE_TYPE: minio
FORGEJO__storage.attachments__MINIO_BASE_PATH: attachments/
FORGEJO__storage.packages__STORAGE_TYPE: minio
FORGEJO__storage.packages__MINIO_BASE_PATH: packages/
FORGEJO__storage.repo-archive__STORAGE_TYPE: minio
FORGEJO__storage.repo-archive__MINIO_BASE_PATH: archives/
# Live git repos still live on local disk under /data/git — S3 is only for LFS
# and other cold blobs. Never point [repository] itself at S3.
# --- CORS (so hephaestus.x and other BCNR-served pages can list public repos) ---
FORGEJO__cors__ENABLED: "true"
FORGEJO__cors__ALLOW_DOMAIN: "*"
FORGEJO__cors__ALLOW_CREDENTIALS: "false"
FORGEJO__cors__METHODS: "GET,HEAD"
# --- Quota (free tier: 500 MB per user) ---
FORGEJO__quota__ENABLED: "true"
FORGEJO__quota__DEFAULT_GROUPS: free
# --- Registration + signin ---
# Local form is disabled via SHOW_REGISTRATION_BUTTON=false + ALLOW_ONLY_EXTERNAL_REGISTRATION=true.
# DISABLE_REGISTRATION must stay FALSE — it blocks OIDC auto-provisioning too, not just the form.
FORGEJO__service__DISABLE_REGISTRATION: "false"
FORGEJO__service__SHOW_REGISTRATION_BUTTON: "false"
FORGEJO__service__ALLOW_ONLY_EXTERNAL_REGISTRATION: "true"
# OIDC callback creates users automatically using the preferred_username claim (= cashaddr).
FORGEJO__oauth2_client__ENABLE_AUTO_REGISTRATION: "true"
FORGEJO__oauth2_client__ACCOUNT_LINKING: "auto"
FORGEJO__oauth2_client__USERNAME: "preferred_username"
FORGEJO__oauth2_client__UPDATE_AVATAR: "false"
# Skip the web installer wizard — everything is already configured
FORGEJO__server__INSTALL_LOCK: "true"
# hephaestus.x isn't in ICANN DNS, so the Forgejo container can't resolve
# it via its default resolver. Map it to the host gateway so OIDC-discovery
# calls (Forgejo → https://hephaestus.x/auth/…) reach Caddy on the host.
extra_hosts:
- "hephaestus.x:host-gateway"
# Caddy proxies / → forgejo:3000 over the internal network. No public HTTP port.
# 22 stays on 2222 for git-over-SSH (no TLS involved).
expose:
- "3000"
ports:
- "2222:22"
volumes:
- forgejo-data:/data
depends_on:
postgres:
condition: service_healthy
auth-proxy:
condition: service_started
volumes:
postgres-data:
forgejo-data:
auth-proxy-keys:
caddy-data:
caddy-config:

10
forgejo/Dockerfile Normal file
View file

@ -0,0 +1,10 @@
# Forgejo + Silent Mode CA cert baked into the trust store, so Forgejo's Go
# client can validate hephaestus.x's cert (issued by Argonautica) when it
# fetches OIDC discovery from https://hephaestus.x/auth/.well-known/... .
#
# Do NOT switch to USER git at the end — parent image's s6-overlay init needs
# to start as root (it drops to `git` internally). USER git broke s6-svscan
# with "unable to open .s6-svscan/lock: Permission denied".
FROM codeberg.org/forgejo/forgejo:10
COPY silent-mode-ca.crt /usr/local/share/ca-certificates/silent-mode-ca.crt
RUN update-ca-certificates

View file

@ -0,0 +1,22 @@
-----BEGIN CERTIFICATE-----
MIIDszCCApugAwIBAgIIQnTLcHP2GA4wDQYJKoZIhvcNAQELBQAwQTEaMBgGA1UE
AxMRQk5TIExvY2FsIFJvb3QgQ0ExIzAhBgNVBAoTGkJOUyAoLmJjaCBvbiBCaXRj
b2luIENhc2gpMB4XDTI2MDgyMTAwMDEwNVoXDTM2MDgyMTAwMDEwNVowQTEaMBgG
A1UEAxMRQk5TIExvY2FsIFJvb3QgQ0ExIzAhBgNVBAoTGkJOUyAoLmJjaCBvbiBC
aXRjb2luIENhc2gpMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4igg
3dQZUNedViiuXNjlwtNBpsNnjTQYCmvFqwBuUH7r5VT1zPzdANMyDwE93NrYWEmO
LQY+SqnlKKOAiaZOKVegCdJ2xUwn8D62GVR3MT7/31+VAZHvh4GqRQkHzDR7/0Ch
EhGom+aZE/N/y30FpTQytUI/0FQJnHdRtIRkRR1fKg8uUI23PhHNMuT6EYjYhObb
LMIUzXnP3f48OmI2A8die/n2sfXwrNBqrtc/OzO2XuQm0uBP38R8CBnuy+W/NeSw
BRB2BgmdawZc3e4MxnqpsTR76QcCy6XLtJVBNRtFwK7/Jb00DV7zX4xRw8BQ1N24
yHXfSh15FvUXJMCplwIDAQABo4GuMIGrMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0P
AQH/BAQDAgEGMB0GA1UdDgQWBBTD3l5ztWFyW1di/HaXt2FUNFz1tzBpBgNVHR4B
Af8EXzBdoCkwBYIDYmNoMAWCA3AycDAFggNiaXQwBYIDbmF2MAaCBHRlc3QwA4IB
eKEwMAqHCAAAAAAAAAAAMCKHIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAMA0GCSqGSIb3DQEBCwUAA4IBAQAL5JTGXJ39nR9q8U8YH4Oaq13XINbZycRv
2yG+PQaNlPCwSOucMvatN1+DQqW5dSTulu2e873KrXraBYjXphFUSfTS8nMEi92U
B5+f0l+MSAJXDKc8RwNJ/7SDYblodUg3bN6x70RHFMlAq6FcIondpTEBbOPdaDDi
pw+mLnQj+55xMXV0yxkgsvc/SxdXyLLm7uTWRzKJQi0N3p4YYOEURIyZ491gprwF
QTt0WeBYmRc1MLXbs9D0Svh6D4d0ovKZawZ83rx+TfM7nSfxV6VubbOV/urXtgyh
84Bcq9+1cZZMNAT86uBW+EF7J70mJlctSmwHdIXdxXDaVtl+rZ4o
-----END CERTIFICATE-----

51
scripts/backup-restic.sh Normal file
View file

@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Nightly backup of Forgejo state to a second sia.storage bucket via restic.
# Belt-and-suspenders: sia.storage's ToS caps liability at ~$100 and doesn't
# guarantee data retention on account termination. This is our recoverability.
#
# Contents:
# - /data/git (bare repos on VPS local disk)
# - postgres (pg_dump of the forgejo database)
#
# Restic itself lives in a small sidecar container; this script is intended to
# be scheduled via `cron` on the VPS host, or `docker compose exec` from a
# systemd timer. See docker-compose.override.yml.example (todo) for wiring.
#
# Retention: keep-daily=7 keep-weekly=4 keep-monthly=12
set -euo pipefail
# shellcheck disable=SC1091
[[ -f "$(dirname "$0")/../.env" ]] && source "$(dirname "$0")/../.env"
: "${SIA_STORAGE_ENDPOINT:?}"
: "${SIA_STORAGE_ACCESS_KEY:?}"
: "${SIA_STORAGE_SECRET_KEY:?}"
: "${SIA_STORAGE_BUCKET_BACKUP:=${SIA_STORAGE_BUCKET}-backup}"
: "${RESTIC_PASSWORD:?RESTIC_PASSWORD must be set in .env (generate with: openssl rand -base64 32)}"
export AWS_ACCESS_KEY_ID="$SIA_STORAGE_ACCESS_KEY"
export AWS_SECRET_ACCESS_KEY="$SIA_STORAGE_SECRET_KEY"
export RESTIC_REPOSITORY="s3:https://${SIA_STORAGE_ENDPOINT}/${SIA_STORAGE_BUCKET_BACKUP}"
export RESTIC_PASSWORD
# Init on first run (idempotent — swallows "already initialised")
restic snapshots >/dev/null 2>&1 || restic init
# 1. Postgres dump (pipe into restic's stdin backup mode)
docker compose exec -T postgres pg_dump -U "${POSTGRES_USER}" "${POSTGRES_DB}" \
| restic backup --stdin --stdin-filename postgres-forgejo.sql --tag postgres
# 2. Git repositories (mounted volume path inside the forgejo container)
docker compose exec forgejo tar -cf - /data/git \
| restic backup --stdin --stdin-filename forgejo-git.tar --tag git
# 3. Prune old snapshots
restic forget --prune \
--keep-daily 7 \
--keep-weekly 4 \
--keep-monthly 12
# 4. Verify a sample of the repo (cheap integrity check)
restic check --read-data-subset=5%
echo "backup ok at $(date -Iseconds)"

68
scripts/bootstrap-auth.sh Normal file
View file

@ -0,0 +1,68 @@
#!/usr/bin/env bash
# One-time bootstrap: register the wallet auth-proxy as Forgejo's OIDC provider.
# Idempotent — safe to re-run; will fail with "already exists" on second run.
#
# Run this AFTER `docker compose up -d` and after Forgejo's healthcheck passes
# (its Postgres schema needs to be initialised before we can add an auth source).
#
# Usage:
# bash scripts/bootstrap-auth.sh
set -euo pipefail
# shellcheck disable=SC1091
[[ -f "$(dirname "$0")/../.env" ]] && source "$(dirname "$0")/../.env"
: "${AUTH_PROXY_CLIENT_ID:?}"
: "${AUTH_PROXY_CLIENT_SECRET:?}"
: "${AUTH_PROXY_ISSUER:?}"
echo "Waiting for Forgejo to be ready…"
for i in {1..60}; do
if docker compose exec -T forgejo forgejo --version >/dev/null 2>&1; then
break
fi
sleep 2
done
# Same wait for auth-proxy — its /.well-known/openid-configuration must return 200
# before Forgejo will accept the auth source (it calls discovery on add).
echo "Waiting for auth-proxy to be ready…"
for i in {1..60}; do
if docker compose exec -T forgejo wget -qO- "${AUTH_PROXY_ISSUER}/.well-known/openid-configuration" >/dev/null 2>&1; then
break
fi
sleep 2
done
ADMIN_USER="${HEPHAESTUS_ADMIN_USER:-hephaestus-admin}"
ADMIN_PW="${HEPHAESTUS_ADMIN_PASSWORD:-$(openssl rand -base64 24 | tr -d '=+/' | cut -c1-24)}"
ADMIN_EMAIL="${HEPHAESTUS_ADMIN_EMAIL:-admin@hephaestus.local}"
echo "Creating admin user '${ADMIN_USER}' (idempotent — 'already exists' is fine)…"
docker compose exec -T --user 1000 forgejo forgejo admin user create \
--admin \
--username "${ADMIN_USER}" \
--email "${ADMIN_EMAIL}" \
--password "${ADMIN_PW}" \
--must-change-password=false 2>&1 | tail -3 || true
echo ""
echo "Registering OIDC auth source 'hephaestus-wallet' in Forgejo…"
docker compose exec -T --user 1000 forgejo forgejo admin auth add-oauth \
--provider openidConnect \
--name hephaestus-wallet \
--key "${AUTH_PROXY_CLIENT_ID}" \
--secret "${AUTH_PROXY_CLIENT_SECRET}" \
--auto-discover-url "${AUTH_PROXY_ISSUER}/.well-known/openid-configuration" \
--scopes "openid profile" \
--group-claim-name "" \
--skip-local-2fa 2>&1 | tail -5
echo ""
echo "=== Bootstrap complete ==="
echo "Admin credentials (save these somewhere — needed for site admin):"
echo " URL: ${FORGEJO_ROOT_URL}"
echo " Username: ${ADMIN_USER}"
echo " Password: ${ADMIN_PW}"
echo ""
echo "For normal use: log in at ${FORGEJO_ROOT_URL} → 'Sign in with hephaestus-wallet'."

83
scripts/push-split.sh Normal file
View file

@ -0,0 +1,83 @@
#!/usr/bin/env bash
# push-split.sh — push a subdirectory of the monorepo working-copy to its
# matching split repo on Hephaestus, preserving history via git-subtree-split.
#
# Since silentmode/silentmode was deleted and each project lives in its own
# repo on the forge (silentmode/theseus, silentmode/ariadne, silentmode/hephaestus,
# silentmode/sirius, …), cross-cutting commits made in this working copy need to
# be routed to whichever remote corresponds to the touched project.
#
# Usage:
# scripts/push-split.sh <project-dir> [remote-name]
#
# Examples:
# scripts/push-split.sh Hephaestus # → git remote 'hephaestus'
# scripts/push-split.sh TheseusNavigator # → git remote 'theseus' (Navigator suffix stripped)
# scripts/push-split.sh AriadneResolver # → git remote 'ariadne' (Resolver suffix stripped)
# scripts/push-split.sh site-sirius-x sirius # → remote 'sirius' (explicit override)
#
# List of configured remotes: `git remote -v`
#
# Notes:
# - Only ONE prefix per invocation. Cross-cutting changes that touched multiple
# projects need to be run once per touched directory.
# - Uses --branch <ephemeral>, pushes, then deletes it. Nothing sticks around.
# - Push target is always <remote>:master. Passing --branch was intentional
# over --squash: split history is preserved so file blame on the split repo
# still points at the real commits.
set -euo pipefail
prefix="${1:?usage: push-split.sh <project-dir> [remote-name]}"
# Derive a remote name from the directory if not passed.
# Heuristic: lowercase, strip Navigator/Resolver suffix. Otherwise pass explicitly.
default_remote=$(echo "$prefix" | tr '[:upper:]' '[:lower:]' | sed 's/navigator$//; s/resolver$//')
remote="${2:-$default_remote}"
[ -d "$prefix" ] || { echo "error: no such directory: $prefix" >&2; exit 1; }
git remote get-url "$remote" >/dev/null 2>&1 || {
echo "error: no git remote named '$remote'." >&2
echo "available remotes: $(git remote | paste -sd ' ' -)" >&2
echo "pass an explicit remote name as the 2nd arg." >&2
exit 1
}
# Uncommitted changes get carried into the split — usually not what you want.
# Warn (but don't block; sometimes you know what you're doing).
if ! git diff --quiet HEAD -- "$prefix" 2>/dev/null; then
echo "warning: $prefix has uncommitted changes — those will NOT be included in the split." >&2
fi
branch="_push_${remote}_$$"
trap 'git branch -D "$branch" >/dev/null 2>&1 || true' EXIT
echo "→ splitting $prefix/ into ephemeral branch $branch"
git subtree split --prefix="$prefix" HEAD -b "$branch" >/dev/null
# Silent Mode public repos do not carry AI-tool attribution in commit metadata
# (see CLAUDE.md at the repo root). Strip any "Co-Authored-By: Claude ..." /
# "Co-authored-by: Claude ..." trailers from the ephemeral branch before push.
# Local monorepo history is untouched — only what lands on the forge is filtered.
echo "→ scrubbing Co-Authored-By: Claude trailers from $branch"
# filter-branch refuses to run from a dirty working tree ("You have unstaged
# changes"), and with set -e that silently aborted the push whenever another
# session had uncommitted edits. Run it from a throwaway worktree of the
# ephemeral branch instead — refs are shared, the main tree is never touched.
scrubwt=$(mktemp -d "${TMPDIR:-/tmp}/push-split-XXXXXX")
git worktree add --detach --quiet "$scrubwt" "$branch"
if ! (cd "$scrubwt" && FILTER_BRANCH_SQUELCH_WARNING=1 git filter-branch --force --msg-filter '
sed -e "/^Co-[Aa]uthored-[Bb]y: Claude.*/d"
' "$branch" >/dev/null 2>&1); then
git worktree remove --force "$scrubwt" >/dev/null 2>&1 || true
echo "error: trailer scrub failed — not pushing." >&2
exit 1
fi
git worktree remove --force "$scrubwt" >/dev/null 2>&1 || true
# filter-branch leaves refs/original/refs/heads/$branch as a backup — delete it
# so it can not be pushed by accident (--mirror isn't used here, but be safe).
git update-ref -d "refs/original/refs/heads/$branch" 2>/dev/null || true
echo "→ pushing $branch$remote:master …"
git push --force "$remote" "$branch:master"
echo "✓ done — see https://code.silentmode.st/silentmode/$remote"

75
scripts/smoke-test-s3.sh Normal file
View file

@ -0,0 +1,75 @@
#!/usr/bin/env bash
# Verify sia.storage's S3 gateway handles the operations Forgejo needs for LFS.
# Requires: awscli >= 2.x, `xxd`, and env vars from ../.env
#
# Blocks LFS support if any step fails:
# 1. PutObject (small: avatar)
# 2. Multipart upload (large: 100 MB LFS-shaped blob, 3 parts)
# 3. GetObject (read-back)
# 4. HeadObject (metadata)
# 5. ListObjectsV2 (pagination)
# 6. DeleteObject (cleanup)
#
# Usage: ./smoke-test-s3.sh
set -euo pipefail
# shellcheck disable=SC1091
[[ -f ../.env ]] && source ../.env
: "${SIA_STORAGE_ENDPOINT:?SIA_STORAGE_ENDPOINT not set}"
: "${SIA_STORAGE_ACCESS_KEY:?SIA_STORAGE_ACCESS_KEY not set}"
: "${SIA_STORAGE_SECRET_KEY:?SIA_STORAGE_SECRET_KEY not set}"
: "${SIA_STORAGE_BUCKET:?SIA_STORAGE_BUCKET not set}"
export AWS_ACCESS_KEY_ID="$SIA_STORAGE_ACCESS_KEY"
export AWS_SECRET_ACCESS_KEY="$SIA_STORAGE_SECRET_KEY"
export AWS_DEFAULT_REGION=us-east-1
S3="aws s3api --endpoint-url https://${SIA_STORAGE_ENDPOINT}"
S3CLI="aws s3 --endpoint-url https://${SIA_STORAGE_ENDPOINT}"
KEY_SMALL="smoke/small.bin"
KEY_LARGE="smoke/large-100mb.bin"
step() { printf "\n\033[1;36m▸ %s\033[0m\n" "$*"; }
ok() { printf " \033[32m✓\033[0m %s\n" "$*"; }
die() { printf " \033[31m✗ %s\033[0m\n" "$*"; exit 1; }
trap 'rm -f /tmp/smoke-small /tmp/smoke-large /tmp/smoke-large-back' EXIT
step "1. PutObject — small blob"
head -c 4096 /dev/urandom > /tmp/smoke-small
SMALL_HASH=$(sha256sum /tmp/smoke-small | cut -d' ' -f1)
$S3CLI cp /tmp/smoke-small "s3://${SIA_STORAGE_BUCKET}/${KEY_SMALL}" >/dev/null || die "PutObject failed"
ok "uploaded (sha256=${SMALL_HASH:0:12}…)"
step "2. Multipart upload — 100 MB in 3 parts (this is the critical LFS test)"
head -c 104857600 /dev/urandom > /tmp/smoke-large
LARGE_HASH=$(sha256sum /tmp/smoke-large | cut -d' ' -f1)
# Force multipart with a small threshold + chunk size
aws configure set default.s3.multipart_threshold 25MB
aws configure set default.s3.multipart_chunksize 33MB
$S3CLI cp /tmp/smoke-large "s3://${SIA_STORAGE_BUCKET}/${KEY_LARGE}" >/dev/null || die "multipart PUT failed"
ok "multipart upload succeeded (sha256=${LARGE_HASH:0:12}…)"
step "3. GetObject — read-back the large blob"
$S3CLI cp "s3://${SIA_STORAGE_BUCKET}/${KEY_LARGE}" /tmp/smoke-large-back >/dev/null || die "GET failed"
BACK_HASH=$(sha256sum /tmp/smoke-large-back | cut -d' ' -f1)
[[ "$BACK_HASH" == "$LARGE_HASH" ]] || die "checksum mismatch after multipart round-trip"
ok "checksum matches"
step "4. HeadObject"
SIZE=$($S3 head-object --bucket "$SIA_STORAGE_BUCKET" --key "$KEY_LARGE" --query 'ContentLength' --output text) || die "HEAD failed"
[[ "$SIZE" == "104857600" ]] || die "ContentLength mismatch: $SIZE"
ok "size = $SIZE"
step "5. ListObjectsV2 — prefix scan"
COUNT=$($S3 list-objects-v2 --bucket "$SIA_STORAGE_BUCKET" --prefix "smoke/" --query 'length(Contents)' --output text) || die "LIST failed"
[[ "$COUNT" -ge 2 ]] || die "expected >=2 objects under smoke/, got $COUNT"
ok "found $COUNT objects"
step "6. DeleteObject — cleanup"
$S3 delete-object --bucket "$SIA_STORAGE_BUCKET" --key "$KEY_SMALL" >/dev/null
$S3 delete-object --bucket "$SIA_STORAGE_BUCKET" --key "$KEY_LARGE" >/dev/null
ok "deleted test objects"
printf "\n\033[1;32mAll checks passed. sia.storage is safe to use as Forgejo's LFS backend.\033[0m\n"

View file

@ -0,0 +1,34 @@
#!/usr/bin/env bash
# sync-hephaestus-x-to-sia.sh — nightly disaster-recovery backup of the
# static hephaestus.x content from VPS to the Sia bucket.
#
# The BCNR record on hephaestus.x currently points at the VPS via `ip:` only,
# so users route through this VPS. This script keeps a parallel copy on Sia
# for disaster recovery: if the VPS dies, updating the BCNR record from
# `ip:` back to `s3:"bns/hephaestus.x/"` (single Argus CLI call) restores
# service from the Sia bucket in the time it takes for one BCH transaction
# to confirm on chipnet.
#
# Runs on the p2pexchange VPS via systemd timer (hephaestus-x-sia-sync.timer).
set -euo pipefail
cd "$(dirname "$0")/.." # → /root/hephaestus
set -a; source .env; set +a
: "${SIA_STORAGE_ENDPOINT:?}"
: "${SIA_STORAGE_ACCESS_KEY:?}"
: "${SIA_STORAGE_SECRET_KEY:?}"
# Use rclone with an inline S3 remote — no config file needed.
export RCLONE_CONFIG_SIA_TYPE=s3
export RCLONE_CONFIG_SIA_PROVIDER=Other
export RCLONE_CONFIG_SIA_ENDPOINT="https://${SIA_STORAGE_ENDPOINT}"
export RCLONE_CONFIG_SIA_ACCESS_KEY_ID="$SIA_STORAGE_ACCESS_KEY"
export RCLONE_CONFIG_SIA_SECRET_ACCESS_KEY="$SIA_STORAGE_SECRET_KEY"
export RCLONE_CONFIG_SIA_REGION=us-east-1
echo "[$(date -u +%FT%TZ)] syncing /var/www/hephaestus.x/ → sia:bns/hephaestus.x/"
rclone sync /var/www/hephaestus.x/ sia:bns/hephaestus.x/ --checksum
echo "[$(date -u +%FT%TZ)] done. bucket contents:"
rclone size sia:bns/hephaestus.x/