-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathserver.js
More file actions
917 lines (756 loc) · 29 KB
/
Copy pathserver.js
File metadata and controls
917 lines (756 loc) · 29 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
// server.js - Manual BIP47 Auth47 implementation
import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import rateLimit from 'express-rate-limit';
import { MongoClient } from 'mongodb';
import ecc from '@bitcoinerlab/secp256k1';
import { BIP47Factory } from '@dojo-tools/bip47';
import { networks } from '@dojo-tools/bip47/utils';
import { Auth47Verifier } from '@dojo-tools/auth47';
import { bitcoinMessageFactory } from '@dojo-tools/bitcoinjs-message';
import QRCode from 'qrcode';
import crypto from 'crypto';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const PORT = process.env.PORT || 3000;
const PUBLIC_DIR = path.join(__dirname, 'public');
// Request limits. Every one of these was previously unbounded, which let a
// single request pin the CPU, bloat the database, or turn this server into an
// amplifier against paynym.rs.
const MAX_MESSAGE_LENGTH = 500; // matches the textarea maxlength in guestbook.html
const MAX_FOLLOWER_IDS = 50; // per /api/paynym/followers request
const FOLLOWER_CONCURRENCY = 5; // simultaneous upstream fetches
const MAX_QR_TEXT_LENGTH = 512;
// Payment codes are base58; anything else must never reach an upstream URL.
const BASE58_RE = /^[1-9A-HJ-NP-Za-km-z]{1,120}$/;
// TLS is terminated one hop in front of us, so req.ip needs the real client
// address for rate limiting to key on anything meaningful. This number must
// match the real topology: with no proxy, X-Forwarded-For can be spoofed to
// bypass the limits; with two hops, every request looks like one address.
app.set('trust proxy', 1);
// MongoDB connection
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/bip47-guestbook';
let db;
// Connect to MongoDB
async function connectToDatabase() {
try {
const client = new MongoClient(MONGODB_URI);
await client.connect();
console.log('✅ Connected to MongoDB');
db = client.db();
// Create index on payment_code for faster lookups
await db.collection('messages').createIndex({ paymentCode: 1 });
await db.collection('messages').createIndex({ createdAt: -1 });
console.log('✅ Database indexes created');
} catch (error) {
console.error('❌ MongoDB connection error:', error);
// Continue running even if DB fails (for local development)
console.log('⚠️ Running without database - guestbook will be disabled');
}
}
// Initialize database connection
connectToDatabase();
// Security headers. Every asset, font and avatar is served from this origin,
// so the CSP can stay narrow.
//
// script-src is strict: all page scripts live in /js and all behaviour is wired
// through data-action attributes, so there is no inline script to allow. That
// is the control that actually stops injected markup from executing.
//
// style-src still needs 'unsafe-inline' because ~100 style="..." attributes
// remain in the markup. Narrowing that means converting them to classes; it is
// a much smaller risk than inline script, so it is left for a later pass.
app.use((req, res, next) => {
res.setHeader('Content-Security-Policy', [
"default-src 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"font-src 'self'",
"connect-src 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
"base-uri 'none'",
"object-src 'none'"
].join('; '));
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Referrer-Policy', 'no-referrer');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=(), payment=()');
next();
});
// CORS is opened only on the read-only lookup endpoints that the docs page
// advertises as a public API. Auth and guestbook writes stay same-origin so a
// third-party page cannot drive them from a visitor's browser.
const publicApiCors = cors({ origin: '*', methods: ['GET', 'POST'] });
// Rate limits, tuned per endpoint cost. AGENTS.md calls for ~10/min against
// the Paynym API; avatars get a higher ceiling because one profile view loads
// many of them, and they are served from cache after the first hit.
const rateLimitOpts = { standardHeaders: 'draft-7', legacyHeaders: false };
const makeLimiter = (windowMs, limit, message) =>
rateLimit({ ...rateLimitOpts, windowMs, limit, message: { error: message } });
const paynymLimiter = makeLimiter(60 * 1000, 10, 'Too many lookups. Please wait a minute.');
const avatarLimiter = makeLimiter(60 * 1000, 120, 'Too many avatar requests.');
const authLimiter = makeLimiter(15 * 60 * 1000, 30, 'Too many authentication attempts.');
const qrLimiter = makeLimiter(60 * 1000, 30, 'Too many QR requests.');
const submitLimiter = makeLimiter(60 * 60 * 1000, 5, 'Too many messages. Please try again later.');
const labLimiter = makeLimiter(60 * 1000, 60, 'Too many requests.');
// Advertise Tor hidden service to Tor Browser (Onion-Location standard)
if (process.env.ONION_ADDRESS) {
app.use((req, res, next) => {
res.setHeader('Onion-Location', `http://${process.env.ONION_ADDRESS}${req.path}`);
next();
});
}
app.use(express.json({ limit: '32kb' }));
app.use(express.static(PUBLIC_DIR, { maxAge: '1h' }));
// Initialize BIP47 with ECC
const bip47 = BIP47Factory(ecc);
const bitcoinjsMessage = bitcoinMessageFactory(ecc);
// Dynamic callback URL for production deployment
const CALLBACK_URL = process.env.CALLBACK_URL || `http://localhost:${PORT}/callback`;
// Initialize Auth47 Verifier (constructor expects ecc first, then callback URL)
const verifier = new Auth47Verifier(ecc, CALLBACK_URL);
// Store pending authentications (use Redis/DB in production)
const pendingAuths = new Map();
// Generate Auth47 URI
app.get('/start-auth', authLimiter, async (req, res) => {
try {
const nonce = crypto.randomBytes(16).toString('hex');
// Calculate expiry (5 minutes from now)
const expiry = Math.floor(Date.now() / 1000) + 300; // 5 minutes
// Auth47 URI format with both c= and r= for maximum wallet compatibility
// - c= (callback): Used by Samourai/Ashigaru wallets
// - r= (resource): Auth47 spec-compliant (BlueWallet, Sparrow)
// NOTE: Do NOT url-encode the callback URL - wallets expect it unencoded
const uri = `auth47://${nonce}?c=${CALLBACK_URL}&e=${expiry}&r=${CALLBACK_URL}`;
const qr = await QRCode.toDataURL(uri);
// Store nonce with expiry
pendingAuths.set(nonce, {
timestamp: Date.now(),
verified: false,
expiry: expiry
});
// Clean up old nonces (>5 minutes)
for (const [key, value] of pendingAuths.entries()) {
if (Date.now() - value.timestamp > 300000) {
pendingAuths.delete(key);
}
}
console.log(`✅ Generated auth URI with nonce: ${nonce}, expiry: ${expiry}`);
res.json({
uri,
qr,
nonce,
callbackUrl: CALLBACK_URL,
expiry: expiry
});
} catch (error) {
console.error('❌ Error generating auth:', error);
res.status(500).json({ error: error.message });
}
});
// Check auth status (polling endpoint)
app.get('/check-auth/:nonce', authLimiter, (req, res) => {
// Disable caching to ensure fresh auth status
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, private');
res.set('Pragma', 'no-cache');
res.set('Expires', '0');
const { nonce } = req.params;
const auth = pendingAuths.get(nonce);
if (!auth) {
return res.json({ status: 'invalid' });
}
if (auth.verified) {
return res.json({
status: 'verified',
nym: auth.nym,
paymentCode: auth.paymentCode,
challenge: auth.challenge,
signature: auth.signature
});
}
res.json({ status: 'pending' });
});
// Verify Auth47 proof
// --- Auth47 proof verification ----------------------------------------------
// Compares two resource URLs. A trailing slash must not make a different site;
// a different origin or path must.
function sameResource(a, b) {
try {
const norm = (value) => {
const url = new URL(value);
return url.origin.toLowerCase() + url.pathname.replace(/\/+$/, '') + url.search;
};
return norm(a) === norm(b);
} catch {
return false;
}
}
// Validates an Auth47 proof against a pending challenge.
//
// expectedResource is REQUIRED on purpose. A verifier that takes only the proof
// answers "is this signed?" when the question is "is this signed FOR ME?".
// Auth47Verifier.verifyProof() checks that the challenge's `r` parses as an
// http(s) URL, but it has no way to know which URL is ours. Without the
// comparison below, an attacker can request a live nonce from us, show a victim
// the same challenge with `r` pointing at the attacker's site, and relay the
// victim's genuine signature back here to open a session in the victim's name.
// Nonce expiry, single use and signature validity do not prevent that; only
// binding the proof to this site's resource does.
function verifyAuth47Proof(proof, expectedResource) {
if (!expectedResource) {
throw new Error('verifyAuth47Proof requires an expected resource');
}
const { challenge, nym, signature } = proof ?? {};
if (!challenge || !nym || !signature) {
return { ok: false, error: 'Missing required fields: challenge, nym, signature' };
}
let challengeUrl;
try {
challengeUrl = new URL(challenge);
} catch {
return { ok: false, error: 'Invalid challenge format' };
}
const nonce = challengeUrl.hostname || challengeUrl.pathname.replace(/^\/\//, '');
const params = challengeUrl.searchParams;
const challengeExpiry = params.get('e');
if (!challengeExpiry) {
return { ok: false, error: 'Missing expiry parameter in challenge' };
}
const auth = pendingAuths.get(nonce);
if (!auth) {
return { ok: false, error: 'Invalid or expired nonce' };
}
const expiryTime = Number.parseInt(challengeExpiry, 10);
if (!Number.isFinite(expiryTime) || expiryTime <= Math.floor(Date.now() / 1000)) {
return { ok: false, error: 'Challenge has expired' };
}
if (auth.expiry !== expiryTime) {
return { ok: false, error: 'Expiry mismatch in challenge' };
}
if (auth.verified) {
return { ok: false, error: 'Nonce already used' };
}
// The resource binding. See the note above: this is the check that makes the
// wallet's "you are signing in to X" display mean anything server-side.
const resource = params.get('r');
if (!resource) {
return { ok: false, error: 'Missing resource parameter in challenge' };
}
if (!sameResource(resource, expectedResource)) {
console.error(`🚨 Resource mismatch: proof signed for "${resource}", expected "${expectedResource}"`);
return { ok: false, error: 'Proof was signed for a different site' };
}
const verifiedProof = verifier.verifyProof(proof, 'bitcoin');
if (verifiedProof.result !== 'ok') {
return { ok: false, error: verifiedProof.error };
}
return { ok: true, auth, nonce };
}
// Records a successful verification against its pending challenge.
function markVerified(auth, proof) {
auth.verified = true;
auth.nym = proof.nym;
auth.paymentCode = proof.nym;
auth.challenge = proof.challenge;
auth.signature = proof.signature;
}
// Verify Auth47 proof
app.post('/verify', authLimiter, async (req, res) => {
try {
console.log('📥 Received verification request');
const result = verifyAuth47Proof(req.body, CALLBACK_URL);
if (!result.ok) {
console.error(`❌ Verification failed: ${result.error}`);
return res.status(400).json({ result: 'error', error: result.error });
}
markVerified(result.auth, req.body);
console.log(`🎉 Authentication successful for ${req.body.nym}`);
res.json({
result: 'ok',
nym: req.body.nym,
payment_code: req.body.nym
});
} catch (error) {
console.error('💥 Verification error:', error);
res.status(400).json({
result: 'error',
error: error.message
});
}
});
// Callback endpoint (displayed after wallet scans)
app.get('/callback', (req, res) => {
res.sendFile(path.join(PUBLIC_DIR, 'callback.html'));
});
// Handle Auth47 wallet callback (POST request from wallet).
// Runs the identical verification as /verify - the resource binding must not
// depend on which entry point the wallet happens to use.
app.post('/callback', authLimiter, async (req, res) => {
try {
console.log('📥 Received Auth47 callback');
const result = verifyAuth47Proof(req.body, CALLBACK_URL);
if (!result.ok) {
console.error(`❌ Callback verification failed: ${result.error}`);
return res.status(400).sendFile(path.join(PUBLIC_DIR, 'callback.html'));
}
markVerified(result.auth, req.body);
console.log(`🎉 Authentication successful via callback for ${req.body.nym}`);
// Redirect to the callback page with the nonce so it can poll auth status
return res.redirect(`/callback?nonce=${result.nonce}`);
} catch (error) {
console.error('💥 Callback error:', error);
res.status(400).sendFile(path.join(PUBLIC_DIR, 'callback.html'));
}
});
// Health check
app.get('/health', (req, res) => {
res.json({
status: 'ok',
database: db ? 'connected' : 'unavailable'
});
});
// --- Paynym upstream helpers ------------------------------------------------
// Overridable so the proxy can be exercised against a stub in tests.
const PAYNYM_ORIGIN = process.env.PAYNYM_ORIGIN || 'https://paynym.rs';
const PAYNYM_API = `${PAYNYM_ORIGIN}/api/v1/nym/`;
const UPSTREAM_TIMEOUT_MS = 8000;
const NYM_CACHE_TTL = 5 * 60 * 1000;
const AVATAR_CACHE_TTL = 60 * 60 * 1000;
const nymCache = new Map();
const avatarCache = new Map();
function cacheGet(cache, key, ttl) {
const hit = cache.get(key);
if (!hit) return null;
if (Date.now() - hit.timestamp > ttl) {
cache.delete(key);
return null;
}
return hit.value;
}
function cacheSet(cache, key, value, maxEntries = 500) {
// Oldest-first eviction keeps these caches from growing without bound.
if (cache.size >= maxEntries) cache.delete(cache.keys().next().value);
cache.set(key, { value, timestamp: Date.now() });
}
function primaryCodeOf(data) {
return data?.codes?.length ? data.codes[0].code : null;
}
// Avatars are proxied through this server rather than linked straight to
// paynym.rs, so a visitor's browser never contacts a third party.
function avatarPath(code) {
return code ? `/api/paynym/avatar/${code}` : null;
}
async function fetchNym(nym) {
const cached = cacheGet(nymCache, nym, NYM_CACHE_TTL);
if (cached) return cached;
const response = await fetch(PAYNYM_API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nym }),
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS)
});
const text = await response.text();
if (!response.ok || !text || text.trim() === '') return null;
const data = JSON.parse(text);
cacheSet(nymCache, nym, data);
return data;
}
// Same as fetchNym, but swallows failures for the call sites where a missing
// Paynym is not an error worth failing the whole request over.
async function fetchNymSafe(nym) {
try {
return await fetchNym(nym);
} catch (error) {
console.error(`❌ Paynym fetch failed for ${nym}:`, error.message);
return null;
}
}
// Runs fn over items with a ceiling on simultaneous work. Promise.all over an
// unbounded array previously let one request fan out arbitrarily wide upstream.
async function mapWithConcurrency(items, limit, fn) {
const results = new Array(items.length);
let next = 0;
const worker = async () => {
while (next < items.length) {
const i = next++;
results[i] = await fn(items[i], i);
}
};
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
return results;
}
// Paynym API proxy endpoint
app.post('/api/paynym/lookup', paynymLimiter, publicApiCors, async (req, res) => {
try {
const { nym } = req.body;
if (!nym || typeof nym !== 'string') {
return res.status(400).json({ error: 'Missing nym parameter' });
}
if (nym.length > 120) {
return res.status(400).json({ error: 'nym parameter is too long' });
}
const cached = cacheGet(nymCache, nym, NYM_CACHE_TTL);
if (cached) {
console.log(`✅ Paynym cache hit: ${cached.nymName}`);
return res.json(cached);
}
console.log(`🔍 Looking up Paynym: ${nym}`);
let data;
try {
data = await fetchNym(nym);
} catch (parseError) {
console.error(`❌ Failed to read API response:`, parseError.message);
return res.status(502).json({ error: 'Invalid response from Paynym API' });
}
if (!data) {
console.error(`❌ Paynym lookup failed: no result from API`);
return res.status(404).json({
error: 'Paynym not found. Please check the nymID or nymName and try again.'
});
}
console.log(`✅ Paynym found: ${data.nymName}`);
res.json(data);
} catch (error) {
console.error('💥 Paynym lookup error:', error);
res.status(500).json({ error: 'Failed to lookup Paynym' });
}
});
// Avatar proxy. Keeps visitor IPs off paynym.rs and lets the strict img-src
// CSP stay at 'self'. The payment code is validated before it reaches a URL.
app.get('/api/paynym/avatar/:code', avatarLimiter, async (req, res) => {
try {
const { code } = req.params;
if (!BASE58_RE.test(code)) {
return res.status(400).json({ error: 'Invalid payment code' });
}
const cached = cacheGet(avatarCache, code, AVATAR_CACHE_TTL);
if (cached) {
res.setHeader('Content-Type', cached.contentType);
res.setHeader('Cache-Control', 'public, max-age=86400');
return res.send(cached.body);
}
const upstream = await fetch(`${PAYNYM_ORIGIN}/${code}/avatar`, {
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS)
});
if (!upstream.ok) {
return res.status(404).json({ error: 'Avatar not found' });
}
const contentType = upstream.headers.get('content-type') || '';
if (!contentType.startsWith('image/')) {
console.error(`❌ Unexpected avatar content-type: ${contentType}`);
return res.status(502).json({ error: 'Unexpected avatar response' });
}
const body = Buffer.from(await upstream.arrayBuffer());
cacheSet(avatarCache, code, { body, contentType }, 200);
res.setHeader('Content-Type', contentType);
res.setHeader('Cache-Control', 'public, max-age=86400');
res.send(body);
} catch (error) {
console.error('💥 Avatar proxy error:', error.message);
res.status(502).json({ error: 'Failed to fetch avatar' });
}
});
// Batch Paynym details endpoint for followers
app.post('/api/paynym/followers', paynymLimiter, publicApiCors, async (req, res) => {
try {
const { nymIds } = req.body;
if (!nymIds || !Array.isArray(nymIds)) {
return res.status(400).json({ error: 'Missing or invalid nymIds parameter' });
}
if (nymIds.length === 0) {
return res.json([]);
}
// Cap the batch and the concurrency. Without both, one request could fan
// out arbitrarily many simultaneous fetches at paynym.rs.
const requested = nymIds
.filter(id => typeof id === 'string' && id.length <= 120)
.slice(0, MAX_FOLLOWER_IDS);
if (requested.length < nymIds.length) {
console.log(`⚠️ Trimmed follower batch from ${nymIds.length} to ${requested.length}`);
}
console.log(`🔍 Fetching details for ${requested.length} followers`);
const followers = await mapWithConcurrency(requested, FOLLOWER_CONCURRENCY, async (nymId) => {
const data = await fetchNymSafe(nymId);
if (!data) return null;
const primaryCode = primaryCodeOf(data);
return {
nymId: data.nymID,
nymName: data.nymName || 'Unknown',
avatarUrl: avatarPath(primaryCode),
primaryCode: primaryCode || ''
};
});
const validFollowers = followers.filter(Boolean);
console.log(`✅ Successfully fetched ${validFollowers.length} follower details`);
res.json(validFollowers);
} catch (error) {
console.error('💥 Batch followers error:', error);
res.status(500).json({
error: 'Failed to fetch follower details'
});
}
});
// Auth page route
app.get('/auth', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'auth.html'));
});
// Paynym Explorer page route
app.get('/paynym', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'paynym.html'));
});
// Root route - serve the main terminal interface
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
// BIP47 LAB API endpoints - Client-side payment code tools
// Payment Code Validator
app.post('/api/bip47/validate', labLimiter, publicApiCors, (req, res) => {
try {
const { paymentCode } = req.body;
if (!paymentCode || typeof paymentCode !== 'string') {
return res.status(400).json({ error: 'Payment code required' });
}
if (paymentCode.length > 200) {
return res.status(400).json({ error: 'Payment code is too long' });
}
const checks = {
format: paymentCode.startsWith('PM8T'),
length: paymentCode.length === 116,
base58: /^[1-9A-HJ-NP-Za-km-z]+$/.test(paymentCode),
checksum: false,
version: false
};
// Check checksum by trying to parse
try {
const pc = bip47.fromBase58(paymentCode);
checks.checksum = true;
checks.version = true;
} catch (e) {
checks.checksum = false;
}
const isValid = checks.format && checks.length && checks.base58 &&
checks.checksum && checks.version;
res.json({
valid: isValid,
checks,
details: isValid ? {
type: 'BIP47 Payment Code v1',
features: 'Reusable payment codes for stealth addresses',
warning: 'Always verify payment codes before use'
} : null
});
} catch (error) {
console.error('💥 Validation error:', error);
res.status(500).json({ error: 'Validation failed: ' + error.message });
}
});
// BIP47 Message Verifier - Verify message signed with notification address
app.post('/api/bip47/verify-message', labLimiter, publicApiCors, async (req, res) => {
try {
const { paymentCode, message, signature } = req.body;
if (!paymentCode || !message || !signature) {
return res.status(400).json({
error: 'Missing required fields: paymentCode, message, signature'
});
}
console.log(`🔍 Verifying message for payment code: ${paymentCode.substring(0, 20)}...`);
// Parse the payment code
let paynym;
try {
paynym = bip47.fromBase58(paymentCode);
} catch (e) {
console.error('❌ Invalid payment code:', e.message);
return res.status(400).json({
valid: false,
error: 'Invalid payment code format'
});
}
// Get the notification address from the payment code
const notificationAddress = paynym.getNotificationAddress();
console.log(`📧 Notification address: ${notificationAddress}`);
// Verify the signature
let isValid;
try {
isValid = bitcoinjsMessage.verify(
message,
notificationAddress,
signature,
networks.bitcoin.messagePrefix
);
} catch (e) {
console.error('❌ Signature verification error:', e.message);
return res.json({
valid: false,
error: 'Invalid signature format',
notificationAddress
});
}
// Try to fetch Paynym details for additional info
const paynymData = await fetchNymSafe(paymentCode);
const nymName = paynymData?.nymName || null;
const avatarUrl = avatarPath(primaryCodeOf(paynymData));
console.log(`${isValid ? '✅' : '❌'} Message verification ${isValid ? 'successful' : 'failed'}`);
res.json({
valid: isValid,
notificationAddress,
nymName,
avatarUrl,
paymentCode
});
} catch (error) {
console.error('💥 Message verification error:', error);
res.status(500).json({
valid: false,
error: 'Verification failed: ' + error.message
});
}
});
// Lab page route
app.get('/lab', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'lab.html'));
});
// Guestbook page route
app.get('/guestbook', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'guestbook.html'));
});
// Documentation page route
app.get('/docs', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'docs.html'));
});
// About page route
app.get('/about', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'about.html'));
});
// QR Code generation endpoint for payment codes
app.get('/api/qr', qrLimiter, async (req, res) => {
try {
const { text } = req.query;
if (!text || typeof text !== 'string') {
return res.status(400).json({ error: 'Missing text parameter' });
}
// Unbounded input here was a cheap way to make the server do expensive work.
if (text.length > MAX_QR_TEXT_LENGTH) {
return res.status(400).json({
error: `Text too long (max ${MAX_QR_TEXT_LENGTH} characters)`
});
}
const qr = await QRCode.toDataURL(text);
res.json({ qr });
} catch (error) {
console.error('💥 QR generation error:', error);
res.status(500).json({ error: 'Failed to generate QR code' });
}
});
// Guestbook API endpoints
// GET /api/guestbook/messages - List all verified messages
app.get('/api/guestbook/messages', async (req, res) => {
try {
if (!db) {
return res.status(503).json({
error: 'Database not available'
});
}
const messages = await db.collection('messages')
.find({ verified: true })
.sort({ createdAt: -1 })
.toArray();
// Rows written before the avatar proxy existed hold absolute paynym.rs
// URLs. Rewrite them on read so old messages keep their avatars without
// needing a migration, and without the browser hitting a third party.
const normalized = messages.map(msg => {
const legacy = typeof msg.nymAvatar === 'string'
? msg.nymAvatar.match(/^https?:\/\/paynym\.rs\/([1-9A-HJ-NP-Za-km-z]+)\/avatar$/)
: null;
return legacy ? { ...msg, nymAvatar: avatarPath(legacy[1]) } : msg;
});
console.log(`✅ Retrieved ${normalized.length} messages`);
res.json(normalized);
} catch (error) {
console.error('💥 Error fetching messages:', error);
res.status(500).json({ error: 'Failed to fetch messages' });
}
});
// POST /api/guestbook/submit - Submit new message with Auth47
app.post('/api/guestbook/submit', submitLimiter, async (req, res) => {
try {
const { nonce, message, challenge, signature, nym } = req.body;
if (!nonce || !message || !challenge || !signature || !nym) {
return res.status(400).json({
error: 'Missing required fields: nonce, message, challenge, signature, nym'
});
}
// The textarea enforces this client-side; the server has to as well, or
// anyone posting directly to the API can store arbitrarily large documents.
if (typeof message !== 'string' || message.trim().length === 0) {
return res.status(400).json({ error: 'Message must be a non-empty string' });
}
const trimmedMessage = message.trim();
if (trimmedMessage.length > MAX_MESSAGE_LENGTH) {
return res.status(400).json({
error: `Message too long (max ${MAX_MESSAGE_LENGTH} characters)`
});
}
if (!db) {
return res.status(503).json({
error: 'Database not available'
});
}
// Verify the Auth47 authentication
const auth = pendingAuths.get(nonce);
if (!auth || !auth.verified || auth.paymentCode !== nym) {
return res.status(401).json({
error: 'Invalid or expired authentication'
});
}
console.log(`📝 Submitting message from ${nym}`);
// Fetch Paynym details including avatar
const paynymData = await fetchNymSafe(nym);
const nymName = paynymData?.nymName || nym;
const nymAvatar = avatarPath(primaryCodeOf(paynymData));
if (paynymData) {
console.log(`✅ Fetched Paynym: ${nymName}, avatar: ${nymAvatar ? 'yes' : 'no'}`);
}
// Store message in database
const messageDoc = {
paymentCode: nym,
nymName,
nymAvatar,
message: trimmedMessage,
signature,
verified: true,
createdAt: new Date(),
nonce
};
await db.collection('messages').insertOne(messageDoc);
console.log(`✅ Message saved for ${nymName}`);
// Mark nonce as used to prevent reuse
pendingAuths.delete(nonce);
res.json({
success: true,
message: 'Message submitted successfully',
data: messageDoc
});
} catch (error) {
console.error('💥 Error submitting message:', error);
res.status(500).json({ error: 'Failed to submit message' });
}
});
// Unknown routes. Without this, Express serves its own unstyled HTML page.
app.use((req, res) => {
if (req.path.startsWith('/api/')) {
return res.status(404).json({ error: 'Not found' });
}
res.status(404).sendFile(path.join(PUBLIC_DIR, '404.html'));
});
app.listen(PORT, () => {
console.log('\n🟢 BIP47 Terminal Server running!');
console.log(`→ http://localhost:${PORT}`);
console.log(`→ Callback: ${CALLBACK_URL}`);
console.log(`→ Using @bitcoinerlab/secp256k1\n`);
});