Snapshot in-progress work: Ariadne mobile, Theseus password manager, Hephaestus
Several concurrent workstreams committed together as a checkpoint: - Ariadne mobile resolver — BchFetcher/Bns/MainActivity resolution logic, AndroidManifest + build.ps1 - Theseus password manager — settings.html/chrome.html/settings-preload.js UI + main.js wiring + package.json resource; Argus password-vault.js, record-picker.js (+ tests) and resolver-web.d.ts - Hephaestus — new BCH-wallet OIDC auth-proxy + Forgejo docker-compose and restic/S3 scripts (secrets referenced via env only; Hephaestus/.env is gitignored) - Argus public-gateway.mjs updates - Docs — root README, Email README/RUNBOOK, VPS access runbooks (Checkers/Deviant), site/hermes, WebsiteDev registry + faster-blocks, Failures/ AAAA-mangle writeup, Decentralized Storage map, coordination notes - .gitignore — exclude /.keys/ and Hephaestus/.env
This commit is contained in:
commit
3c044d42df
15 changed files with 3037 additions and 0 deletions
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
64
auth-proxy/public/login.css
Normal file
64
auth-proxy/public/login.css
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
: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: 520px;
|
||||||
|
margin: 8vh auto;
|
||||||
|
padding: 32px 28px;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
h1 { font-size: 22px; margin: 0 0 4px; }
|
||||||
|
p.lede { color: var(--muted); margin: 0 0 24px; font-size: 14px; }
|
||||||
|
.tab-row { display: flex; gap: 8px; margin-bottom: 20px; border-bottom: 1px solid var(--border); }
|
||||||
|
.tab {
|
||||||
|
padding: 10px 14px; cursor: pointer; background: transparent; border: none;
|
||||||
|
color: var(--muted); font: inherit; border-bottom: 2px solid transparent;
|
||||||
|
}
|
||||||
|
.tab[aria-selected="true"] { color: var(--text); border-bottom-color: var(--accent); }
|
||||||
|
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; }
|
||||||
298
auth-proxy/public/wallet.js
Normal file
298
auth-proxy/public/wallet.js
Normal file
|
|
@ -0,0 +1,298 @@
|
||||||
|
/**
|
||||||
|
* 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 to Hephaestus</h1>
|
||||||
|
<p class="lede">No email. No password reset. Your wallet is your identity.</p>
|
||||||
|
<div class="tab-row" role="tablist">
|
||||||
|
<button class="tab" aria-selected="true" data-tab="generate">Generate new</button>
|
||||||
|
<button class="tab" aria-selected="false" data-tab="import">Import existing</button>
|
||||||
|
</div>
|
||||||
|
<div id="panel"></div>
|
||||||
|
</div>
|
||||||
|
`));
|
||||||
|
app.querySelectorAll(".tab").forEach((b) => {
|
||||||
|
b.addEventListener("click", () => {
|
||||||
|
app.querySelectorAll(".tab").forEach((x) =>
|
||||||
|
x.setAttribute("aria-selected", x === b ? "true" : "false"));
|
||||||
|
renderPanel(b.dataset.tab);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
renderPanel("generate");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPanel(kind) {
|
||||||
|
const panel = document.getElementById("panel");
|
||||||
|
panel.innerHTML = "";
|
||||||
|
if (kind === "generate") {
|
||||||
|
const mnemonic = generateBip39Mnemonic();
|
||||||
|
panel.appendChild(el(`
|
||||||
|
<div>
|
||||||
|
<label>Your new recovery phrase (write it down — this is the only way to recover your account)</label>
|
||||||
|
<div class="mnemonic-box">${mnemonic}</div>
|
||||||
|
<label>Set a passphrase to encrypt this wallet in your browser</label>
|
||||||
|
<input type="password" id="pw" autocomplete="new-password" placeholder="min 8 chars">
|
||||||
|
<p class="small">The passphrase never leaves your device. If you lose both the phrase and the passphrase your account is gone — we cannot help.</p>
|
||||||
|
<button class="primary" id="go">Create wallet & sign in</button>
|
||||||
|
<p class="warn hidden" id="err"></p>
|
||||||
|
</div>
|
||||||
|
`));
|
||||||
|
panel.querySelector("#go").addEventListener("click", async () => {
|
||||||
|
const pw = panel.querySelector("#pw").value;
|
||||||
|
if (pw.length < 8) return showErr(panel, "passphrase must be at least 8 chars");
|
||||||
|
await onboardAndLogin(mnemonic, pw, panel);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
panel.appendChild(el(`
|
||||||
|
<div>
|
||||||
|
<label>Paste your BIP-39 recovery phrase (12 or 24 words)</label>
|
||||||
|
<textarea id="mn" spellcheck="false" autocomplete="off"></textarea>
|
||||||
|
<label>Set a passphrase to encrypt this wallet in your browser</label>
|
||||||
|
<input type="password" id="pw" autocomplete="new-password" placeholder="min 8 chars">
|
||||||
|
<button class="primary" id="go">Import & sign in</button>
|
||||||
|
<p class="warn hidden" id="err"></p>
|
||||||
|
</div>
|
||||||
|
`));
|
||||||
|
panel.querySelector("#go").addEventListener("click", async () => {
|
||||||
|
const mn = panel.querySelector("#mn").value.trim().replace(/\s+/g, " ");
|
||||||
|
const pw = panel.querySelector("#pw").value;
|
||||||
|
if (pw.length < 8) return showErr(panel, "passphrase must be at least 8 chars");
|
||||||
|
try { await keypairFromMnemonic(mn); }
|
||||||
|
catch { return showErr(panel, "invalid recovery phrase"); }
|
||||||
|
await onboardAndLogin(mn, pw, panel);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
248
auth-proxy/src/index.ts
Normal file
248
auth-proxy/src/index.ts
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
/**
|
||||||
|
* 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 ?? {};
|
||||||
|
if (!cashaddr || !cashaddr.startsWith("bitcoincash:")) {
|
||||||
|
return reply.code(400).send({ error: "cashaddr must start with bitcoincash:" });
|
||||||
|
}
|
||||||
|
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 ---
|
||||||
|
app.post<{
|
||||||
|
Body: { nonce: string; signature: string };
|
||||||
|
}>("/verify", async (req, reply) => {
|
||||||
|
const { nonce, signature } = 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 = rec.redirectUri ?? FORGEJO_REDIRECT_URI;
|
||||||
|
const code = mintAuthCode(recovered, CLIENT_ID, redirectUri);
|
||||||
|
const url = new URL(redirectUri);
|
||||||
|
url.searchParams.set("code", code);
|
||||||
|
if (rec.clientState) url.searchParams.set("state", rec.clientState);
|
||||||
|
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);
|
||||||
|
});
|
||||||
74
auth-proxy/src/oidc.ts
Normal file
74
auth-proxy/src/oidc.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
/**
|
||||||
|
* 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");
|
||||||
|
const jwt = await new SignJWT({
|
||||||
|
// sub = cashaddr; also expose as preferred_username so Forgejo shows it
|
||||||
|
preferred_username: params.subject,
|
||||||
|
...(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;
|
||||||
|
}
|
||||||
115
auth-proxy/src/verify.ts
Normal file
115
auth-proxy/src/verify.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export function verifyBchSignedMessage(
|
||||||
|
claimedCashaddr: string,
|
||||||
|
message: string,
|
||||||
|
signatureBase64: string,
|
||||||
|
prefix: "bitcoincash" | "bchtest" = "bitcoincash",
|
||||||
|
): string | 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/**/*"]
|
||||||
|
}
|
||||||
90
docker-compose.yml
Normal file
90
docker-compose.yml
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
services:
|
||||||
|
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
|
||||||
|
ports:
|
||||||
|
- "4000:4000"
|
||||||
|
volumes:
|
||||||
|
- auth-proxy-keys:/data
|
||||||
|
|
||||||
|
forgejo:
|
||||||
|
image: codeberg.org/forgejo/forgejo:10
|
||||||
|
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}
|
||||||
|
# --- sia.storage S3 backends ---
|
||||||
|
FORGEJO__storage.lfs__STORAGE_TYPE: minio
|
||||||
|
FORGEJO__storage.lfs__MINIO_ENDPOINT: ${SIA_STORAGE_ENDPOINT}
|
||||||
|
FORGEJO__storage.lfs__MINIO_ACCESS_KEY_ID: ${SIA_STORAGE_ACCESS_KEY}
|
||||||
|
FORGEJO__storage.lfs__MINIO_SECRET_ACCESS_KEY: ${SIA_STORAGE_SECRET_KEY}
|
||||||
|
FORGEJO__storage.lfs__MINIO_BUCKET: ${SIA_STORAGE_BUCKET}
|
||||||
|
FORGEJO__storage.lfs__MINIO_LOCATION: us-east-1
|
||||||
|
FORGEJO__storage.lfs__MINIO_USE_SSL: "true"
|
||||||
|
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/
|
||||||
|
# (attachments/packages/archives inherit endpoint+creds+bucket from [storage] via shared minio settings below)
|
||||||
|
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_USE_SSL: "true"
|
||||||
|
# --- Quota (free tier: 500 MB per user across repos+LFS+attachments+packages) ---
|
||||||
|
FORGEJO__quota__ENABLED: "true"
|
||||||
|
FORGEJO__quota__DEFAULT_GROUPS: free
|
||||||
|
# --- Registration: disabled (users bootstrap by wallet OIDC login) ---
|
||||||
|
FORGEJO__service__DISABLE_REGISTRATION: "true"
|
||||||
|
FORGEJO__service__ALLOW_ONLY_EXTERNAL_REGISTRATION: "true"
|
||||||
|
FORGEJO__service__SHOW_REGISTRATION_BUTTON: "false"
|
||||||
|
FORGEJO__service__ENABLE_INTERNAL_SIGNIN: "false"
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
- "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:
|
||||||
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)"
|
||||||
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"
|
||||||
Loading…
Add table
Reference in a new issue