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
// Builds the password-gated index.html for the FoxyVPN tutorial.
// Usage: node build-gate.mjs < passcode.txt (passcode on stdin, never stored in files)
// Encrypts plain.html (AES-256-GCM, key from PBKDF2-SHA256) and writes index.html
// containing only the gate UI + ciphertext. Decryption happens client-side via WebCrypto.
import { readFileSync, writeFileSync } from 'node:fs';
import { webcrypto } from 'node:crypto';
const subtle = webcrypto.subtle;
const ITER = 310000;
const DIR = '/home/hatch/workspace/foxyvpn-tutorial-site';
async function main() {
const pw = readFileSync(0, 'utf8').trim();
if (!pw) throw new Error('empty passcode on stdin');
const plain = readFileSync(`${DIR}/plain.html`);
const salt = webcrypto.getRandomValues(new Uint8Array(16));
const iv = webcrypto.getRandomValues(new Uint8Array(12));
const enc = new TextEncoder();
const keyMat = await subtle.importKey('raw', enc.encode(pw), 'PBKDF2', false, ['deriveKey']);
const encKey = await subtle.deriveKey(
{ name: 'PBKDF2', salt, iterations: ITER, hash: 'SHA-256' },
keyMat, { name: 'AES-GCM', length: 256 }, false, ['encrypt']);
const ct = Buffer.from(await subtle.encrypt({ name: 'AES-GCM', iv }, encKey, plain));
// verify round-trip before writing anything
const decKey = await subtle.deriveKey(
{ name: 'PBKDF2', salt, iterations: ITER, hash: 'SHA-256' },
keyMat, { name: 'AES-GCM', length: 256 }, false, ['decrypt']);
const pt = Buffer.from(await subtle.decrypt({ name: 'AES-GCM', iv }, decKey, ct));
if (!pt.equals(plain)) throw new Error('round-trip FAILED');
console.log('round-trip OK | plaintext', plain.length, '-> ciphertext', ct.length);
const b64 = (u8) => Buffer.from(u8).toString('base64');
const payload = JSON.stringify({ iter: ITER, salt: b64(salt), iv: b64(iv), ct: ct.toString('base64') });
const gate = `<!DOCTYPE html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex">
<title>آموزش نصب و راهاندازی FoxyVPN</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{min-height:100vh;display:flex;align-items:center;justify-content:center;
font-family:Tahoma,'Segoe UI',sans-serif;color:#e8ecf4;padding:20px;
background:radial-gradient(1000px 600px at 50% -10%,#2b3a67 0%,#141a30 55%,#0b0f1f 100%)}
.card{width:100%;max-width:380px;background:rgba(255,255,255,.06);
border:1px solid rgba(255,255,255,.12);border-radius:20px;padding:36px 30px;
text-align:center;backdrop-filter:blur(12px);box-shadow:0 24px 60px rgba(0,0,0,.45)}
.lock{width:64px;height:64px;margin:0 auto 18px;border-radius:18px;display:flex;
align-items:center;justify-content:center;background:linear-gradient(135deg,#4f7cff,#7b5cff);
box-shadow:0 8px 24px rgba(99,120,255,.4)}
h1{font-size:20px;margin-bottom:8px}
p.sub{font-size:14px;color:#aab4cf;margin-bottom:22px;line-height:1.9}
input{width:100%;padding:13px 16px;border-radius:12px;border:1px solid rgba(255,255,255,.16);
background:rgba(0,0,0,.3);color:#fff;font-size:16px;text-align:center;outline:none;margin-bottom:12px}
input:focus{border-color:#6b8bff}
button{width:100%;padding:13px;border:none;border-radius:12px;font-size:16px;font-weight:bold;
color:#fff;cursor:pointer;background:linear-gradient(135deg,#4f7cff,#7b5cff)}
button:disabled{opacity:.6;cursor:wait}
.err{color:#ff8f8f;font-size:13px;min-height:22px;margin-top:10px}
.shake{animation:shake .35s}
@keyframes shake{0%,100%{transform:translateX(0)}25%{transform:translateX(-7px)}75%{transform:translateX(7px)}}
</style>
</head>
<body>
<div class="card" id="card">
<div class="lock">
<svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
</div>
<h1>آموزش نصب و راهاندازی FoxyVPN</h1>
<p class="sub">این آموزش با رمز محافظت میشود.<br>رمز را وارد کنید:</p>
<input type="password" id="pw" placeholder="رمز عبور" autocomplete="off" autofocus>
<button id="go">ورود</button>
<p class="err" id="err"></p>
</div>
<script>
const PAYLOAD = __PAYLOAD__;
const pwEl = document.getElementById('pw');
const goEl = document.getElementById('go');
const errEl = document.getElementById('err');
const cardEl = document.getElementById('card');
function fail(msg){
errEl.textContent = msg;
cardEl.classList.remove('shake'); void cardEl.offsetWidth; cardEl.classList.add('shake');
goEl.disabled = false; goEl.textContent = 'ورود';
}
async function unlock(){
const pw = pwEl.value;
if(!pw){ fail('رمز را وارد کنید'); return; }
goEl.disabled = true; goEl.textContent = 'در حال بررسی...'; errEl.textContent = '';
try{
const enc = new TextEncoder();
const b64 = s => Uint8Array.from(atob(s), c => c.charCodeAt(0));
const salt = b64(PAYLOAD.salt), iv = b64(PAYLOAD.iv), ct = b64(PAYLOAD.ct);
const keyMat = await crypto.subtle.importKey('raw', enc.encode(pw), 'PBKDF2', false, ['deriveKey']);
const key = await crypto.subtle.deriveKey(
{ name:'PBKDF2', salt, iterations: PAYLOAD.iter, hash:'SHA-256' },
keyMat, { name:'AES-GCM', length:256 }, false, ['decrypt']);
const pt = await crypto.subtle.decrypt({ name:'AES-GCM', iv }, key, ct);
const html = new TextDecoder().decode(pt);
document.open(); document.write(html); document.close();
}catch(e){ fail('رمز اشتباه است'); }
}
goEl.addEventListener('click', unlock);
pwEl.addEventListener('keydown', e => { if(e.key === 'Enter') unlock(); });
</script>
</body>
</html>`;
const out = gate.replace('__PAYLOAD__', payload);
writeFileSync(`${DIR}/index.html`, out);
console.log('gate written | bytes:', Buffer.byteLength(out));
}
main().catch(e => { console.error(e.message); process.exit(1); });