Initial commit
This commit is contained in:
commit
ff11649813
22 changed files with 3548 additions and 0 deletions
23
.env.example
Normal file
23
.env.example
Normal 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
129
PROTOCOL.md
Normal 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
94
README.md
Normal 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
20
auth-proxy/Dockerfile
Normal 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
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
24
auth-proxy/package.json
Normal 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
118
auth-proxy/public/login.css
Normal 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
353
auth-proxy/public/wallet.js
Normal 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 & 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 & 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 & 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
259
auth-proxy/src/index.ts
Normal 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) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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
85
auth-proxy/src/oidc.ts
Normal 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;
|
||||
}
|
||||
82
auth-proxy/src/sessions.ts
Normal file
82
auth-proxy/src/sessions.ts
Normal 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
130
auth-proxy/src/verify.ts
Normal 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
16
auth-proxy/tsconfig.json
Normal 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
78
caddy/Caddyfile
Normal 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
137
docker-compose.yml
Normal 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
10
forgejo/Dockerfile
Normal 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
|
||||
22
forgejo/silent-mode-ca.crt
Normal file
22
forgejo/silent-mode-ca.crt
Normal 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
51
scripts/backup-restic.sh
Normal 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
68
scripts/bootstrap-auth.sh
Normal 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
83
scripts/push-split.sh
Normal 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
75
scripts/smoke-test-s3.sh
Normal 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"
|
||||
34
scripts/sync-hephaestus-x-to-sia.sh
Normal file
34
scripts/sync-hephaestus-x-to-sia.sh
Normal 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/
|
||||
Loading…
Add table
Reference in a new issue