v2
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
import { createHash, createPublicKey, verify } from 'node:crypto';
|
||||
import { argon2id } from 'hash-wasm';
|
||||
import { z } from 'zod';
|
||||
import { claimMessage, transferMessage, proofMessage, POW, GENESIS, DIFFICULTY, ownerOf,
|
||||
type Block, type Challenge, type ChainState, type Transfer } from '../shared/protocol.ts';
|
||||
|
||||
const hex = z.string().regex(/^[a-f0-9]{64}$/);
|
||||
const publicKey = z.string().regex(/^[A-Za-z0-9+/]+={0,2}$/).max(256);
|
||||
const signature = z.string().regex(/^[A-Za-z0-9+/]+={0,2}$/).max(128);
|
||||
export const mineSchema = z.object({ owner: hex, publicKey, signature,
|
||||
nonce: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER), hash: hex,
|
||||
seed: hex }).strict();
|
||||
export const transferSchema = z.object({ cardHash: hex, from: hex, to: hex,
|
||||
sequence: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), publicKey, signature }).strict();
|
||||
|
||||
export function address(key: string): string {
|
||||
return createHash('sha256').update(Buffer.from(key, 'base64')).digest('hex');
|
||||
}
|
||||
export function validSignature(key: string, owner: string, message: string, sig: string): boolean {
|
||||
try {
|
||||
if (address(key) !== owner) return false;
|
||||
const imported = createPublicKey({ key: Buffer.from(key, 'base64'), format: 'der', type: 'spki' });
|
||||
if (imported.asymmetricKeyType !== 'ec' || imported.asymmetricKeyDetails?.namedCurve !== 'prime256v1') return false;
|
||||
return verify('sha256', Buffer.from(message), { key: imported, dsaEncoding: 'ieee-p1363' }, Buffer.from(sig, 'base64'));
|
||||
} catch { return false; }
|
||||
}
|
||||
export async function powHash(c: Challenge, owner: string, nonce: number): Promise<string> {
|
||||
return argon2id({ ...POW, password: proofMessage(c, owner, nonce),
|
||||
salt: Buffer.from(c.seed, 'hex'), outputType: 'hex' });
|
||||
}
|
||||
export async function verifyBlock(block: Block, previousHash: string, height: number): Promise<boolean> {
|
||||
if (block.previousHash !== previousHash || block.height !== height || block.difficulty !== DIFFICULTY) return false;
|
||||
if (!/^[a-f0-9]{64}$/.test(block.seed) || !/^[a-f0-9]{64}$/.test(block.hash)) return false;
|
||||
if (!Number.isSafeInteger(block.nonce) || block.nonce < 0 || !Number.isSafeInteger(block.createdAt)) return false;
|
||||
if (!Number.isSafeInteger(block.article.pageId) || block.article.pageId <= 0) return false;
|
||||
if (!block.hash.startsWith('0'.repeat(DIFFICULTY))) return false;
|
||||
if (!validSignature(block.publicKey, block.owner, claimMessage(block, block.owner, block.nonce, block.hash), block.signature)) return false;
|
||||
return await powHash(block, block.owner, block.nonce) === block.hash;
|
||||
}
|
||||
export function validTransfer(state: ChainState, t: Transfer): boolean {
|
||||
return transferSchema.safeParse(t).success && t.from !== t.to &&
|
||||
ownerOf(state, t.cardHash) === t.from &&
|
||||
t.sequence === state.transfers.filter(x => x.cardHash === t.cardHash).length &&
|
||||
validSignature(t.publicKey, t.from, transferMessage(t), t.signature);
|
||||
}
|
||||
export async function verifyState(state: ChainState): Promise<boolean> {
|
||||
let previous = GENESIS;
|
||||
for (const [index, block] of state.blocks.entries()) {
|
||||
if (!await verifyBlock(block, previous, index + 1)) return false;
|
||||
previous = block.hash;
|
||||
}
|
||||
const checked: ChainState = { blocks: state.blocks, transfers: [] };
|
||||
for (const t of state.transfers) {
|
||||
if (!validTransfer(checked, t)) return false;
|
||||
checked.transfers.push(t);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
import express from 'express';
|
||||
import helmet from 'helmet';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { mkdir, readFile, writeFile, rename } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { z } from 'zod';
|
||||
import { mineSchema, transferSchema, validSignature, validTransfer, verifyBlock, verifyState } from './chain.ts';
|
||||
import { wikipediaArticle } from './wiki.ts';
|
||||
import { GENESIS, DIFFICULTY, POW, claimMessage, type ChainState, type Challenge, type Block } from '../shared/protocol.ts';
|
||||
|
||||
const root = fileURLToPath(new URL('../', import.meta.url));
|
||||
const dataDir = process.env.WIKIDECK_DATA_DIR ?? resolve(root, 'data');
|
||||
await mkdir(dataDir, { recursive: true });
|
||||
const file = resolve(dataDir, 'chain.json');
|
||||
let state: ChainState = { blocks: [], transfers: [] };
|
||||
try {
|
||||
state = z.object({ blocks: z.array(z.custom<Block>(b => typeof b === 'object' && b !== null)),
|
||||
transfers: z.array(transferSchema) }).parse(JSON.parse(await readFile(file, 'utf8')));
|
||||
if (!await verifyState(state)) throw new Error('Chain verification failed');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||
}
|
||||
async function persist(next: ChainState) {
|
||||
const temp = `${file}.tmp`;
|
||||
await writeFile(temp, JSON.stringify(next, null, 2), { mode: 0o600 });
|
||||
await rename(temp, file);
|
||||
state = next;
|
||||
}
|
||||
async function nextChallenge(): Promise<Challenge> {
|
||||
return { height: state.blocks.length + 1, previousHash: state.blocks.at(-1)?.hash ?? GENESIS,
|
||||
seed: randomBytes(32).toString('hex'), article: await wikipediaArticle(),
|
||||
difficulty: DIFFICULTY, createdAt: Date.now() };
|
||||
}
|
||||
let challenge = await nextChallenge();
|
||||
let busy = false;
|
||||
const app = express();
|
||||
app.disable('x-powered-by');
|
||||
app.use(helmet({ contentSecurityPolicy: { directives: {
|
||||
defaultSrc: ["'self'"], scriptSrc: ["'self'", "'wasm-unsafe-eval'"],
|
||||
imgSrc: ["'self'", 'data:', 'https://upload.wikimedia.org', 'https://thumb.wikimedia.org'],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"], connectSrc: ["'self'"], workerSrc: ["'self'", 'blob:'],
|
||||
upgradeInsecureRequests: null,
|
||||
} }, crossOriginEmbedderPolicy: false, strictTransportSecurity: process.env.NODE_ENV === 'production' }));
|
||||
// Probes do not consume API rate limits or expose chain/wallet information.
|
||||
app.get('/healthz', (_req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.json({ status: 'ok' });
|
||||
});
|
||||
app.use('/api', rateLimit({ windowMs: 60_000, limit: 180, standardHeaders: 'draft-8', legacyHeaders: false }));
|
||||
app.use(express.json({ limit: '8kb' }));
|
||||
app.use('/api', (_req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); });
|
||||
app.use('/api', (req, res, next) => {
|
||||
if (req.method === 'POST') {
|
||||
const origin = req.get('origin');
|
||||
const allowed = new Set(['http://127.0.0.1:5173', 'http://localhost:5173', 'http://127.0.0.1:3001', 'http://localhost:3001', process.env.PUBLIC_ORIGIN]);
|
||||
if (origin && !allowed.has(origin)) { res.status(403).json({ error: 'Origin not allowed' }); return; }
|
||||
if (!req.is('application/json')) { res.status(415).json({ error: 'JSON required' }); return; }
|
||||
}
|
||||
next();
|
||||
});
|
||||
app.get('/api/state', (_req, res) => res.json({ ...state, challenge, pow: POW, network: 'Local prototype' }));
|
||||
app.get('/api/chain', (_req, res) => res.json(state));
|
||||
app.post('/api/mine', rateLimit({ windowMs: 60_000, limit: 20, standardHeaders: 'draft-8', legacyHeaders: false }), async (req, res) => {
|
||||
const result = mineSchema.safeParse(req.body);
|
||||
if (!result.success) { res.status(400).json({ error: 'Invalid mining submission' }); return; }
|
||||
const proof = result.data;
|
||||
if (busy) { res.status(409).json({ error: 'Chain is updating. Refresh your challenge.' }); return; }
|
||||
if (proof.seed !== challenge.seed) { res.status(409).json({ error: 'Someone mined this block first. A new article is ready.' }); return; }
|
||||
if (!validSignature(proof.publicKey, proof.owner, claimMessage(challenge, proof.owner, proof.nonce, proof.hash), proof.signature)) {
|
||||
res.status(403).json({ error: 'Invalid wallet signature' }); return;
|
||||
}
|
||||
busy = true; // Bound expensive verification to one 16 MiB Argon2 job at a time.
|
||||
try {
|
||||
const block: Block = { ...challenge, ...proof };
|
||||
if (!await verifyBlock(block, challenge.previousHash, challenge.height)) {
|
||||
res.status(400).json({ error: 'Invalid proof of work' }); return;
|
||||
}
|
||||
await persist({ ...state, blocks: [...state.blocks, block] });
|
||||
challenge = await nextChallenge();
|
||||
res.status(201).json({ block });
|
||||
} finally { busy = false; }
|
||||
});
|
||||
app.post('/api/transfer', rateLimit({ windowMs: 60_000, limit: 15, standardHeaders: 'draft-8', legacyHeaders: false }), async (req, res) => {
|
||||
const result = transferSchema.safeParse(req.body);
|
||||
if (!result.success) { res.status(400).json({ error: 'Invalid transfer request' }); return; }
|
||||
if (busy) { res.status(409).json({ error: 'Chain is updating. Try again.' }); return; }
|
||||
if (!validTransfer(state, result.data)) { res.status(403).json({ error: 'Invalid signature, ownership, recipient, or transfer sequence' }); return; }
|
||||
busy = true;
|
||||
try {
|
||||
await persist({ ...state, transfers: [...state.transfers, result.data] });
|
||||
res.status(201).json({ ok: true });
|
||||
} finally { busy = false; }
|
||||
});
|
||||
app.use('/api', (_req, res) => { res.status(404).json({ error: 'Unknown API route' }); });
|
||||
app.use(express.static(resolve(root, 'dist')));
|
||||
app.get('/{*path}', (_req, res) => res.sendFile(resolve(root, 'dist/index.html')));
|
||||
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
console.error('Request failed:', err.name);
|
||||
res.status(err instanceof SyntaxError ? 400 : 500).json({ error: err instanceof SyntaxError ? 'Malformed JSON' : 'Request failed; try again.' });
|
||||
});
|
||||
const port = Number(process.env.PORT ?? 3001);
|
||||
const host = process.env.HOST ?? '127.0.0.1';
|
||||
const server = app.listen(port, host, () => {
|
||||
const bound = server.address();
|
||||
console.log(`Wikideck API: http://${host}:${typeof bound === 'object' && bound ? bound.port : port} · ${state.blocks.length} verified blocks`);
|
||||
});
|
||||
// Drain in-flight proof validation and atomic writes before stopping the container.
|
||||
for (const signal of ['SIGTERM', 'SIGINT'] as const) {
|
||||
process.once(signal, () => {
|
||||
const deadline = setTimeout(() => process.exit(1), 25_000);
|
||||
deadline.unref();
|
||||
server.close(() => { clearTimeout(deadline); process.exit(0); });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { z } from 'zod';
|
||||
import type { Article } from '../shared/protocol.ts';
|
||||
import { STARTER_ARTICLES } from '../shared/catalog.ts';
|
||||
|
||||
const pageSchema = z.object({
|
||||
pageid: z.number().int().positive(), title: z.string(), extract: z.string().default(''),
|
||||
lastrevid: z.number().int().nonnegative().default(0),
|
||||
thumbnail: z.object({ source: z.string().url() }).optional(),
|
||||
categories: z.array(z.object({ title: z.string() })).optional(),
|
||||
});
|
||||
|
||||
function category(title: string, categories: string[]): string {
|
||||
const text = `${title} ${categories.join(' ')}`.toLowerCase();
|
||||
if (/astronom|planet|space|galax|moon|star\b/.test(text)) return 'Space';
|
||||
if (/animal|plant|species|ocean|biolog|mollusc/.test(text)) return 'Nature';
|
||||
if (/births|deaths|people|scientist/.test(text)) return 'People';
|
||||
if (/physic|chem|math|science|technolog/.test(text)) return 'Science';
|
||||
if (/histor|ancient|archaeolog|empire/.test(text)) return 'History';
|
||||
return 'Culture';
|
||||
}
|
||||
export async function wikipediaArticle(): Promise<Article> {
|
||||
if (process.env.WIKIDECK_OFFLINE === '1') return { ...STARTER_ARTICLES[Math.floor(Math.random() * STARTER_ARTICLES.length)] };
|
||||
try {
|
||||
const url = new URL('https://en.wikipedia.org/w/api.php');
|
||||
url.search = new URLSearchParams({ action: 'query', format: 'json', formatversion: '2',
|
||||
generator: 'random', grnnamespace: '0', grnlimit: '6', prop: 'extracts|pageimages|info|categories',
|
||||
exintro: '1', explaintext: '1', exchars: '700', piprop: 'thumbnail', pithumbsize: '900', cllimit: '8' }).toString();
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(6000), redirect: 'error',
|
||||
headers: { 'User-Agent': 'Wikideck/0.1 (local educational card-game prototype)', Accept: 'application/json' } });
|
||||
if (!response.ok) throw new Error('Wikipedia unavailable');
|
||||
const raw = await response.text();
|
||||
if (raw.length > 1_000_000) throw new Error('Wikipedia response too large');
|
||||
const result = z.object({ query: z.object({ pages: z.array(pageSchema) }) }).parse(JSON.parse(raw));
|
||||
const page = result.query.pages.find(p => p.extract.length > 120 && p.thumbnail) ?? result.query.pages.find(p => p.extract.length > 120);
|
||||
if (!page) throw new Error('No suitable article');
|
||||
const image = page.thumbnail?.source;
|
||||
return { pageId: page.pageid, title: page.title, extract: page.extract.slice(0, 700),
|
||||
image: image && ['https://upload.wikimedia.org', 'https://thumb.wikimedia.org'].includes(new URL(image).origin) ? image : undefined,
|
||||
url: `https://en.wikipedia.org/?curid=${page.pageid}`, revisionId: page.lastrevid,
|
||||
source: 'live', category: category(page.title, page.categories?.map(c => c.title) ?? []) };
|
||||
} catch {
|
||||
return { ...STARTER_ARTICLES[Math.floor(Math.random() * STARTER_ARTICLES.length)] };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user