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