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