arblant
foxy-tutorial-site / build-gate.mjs
build-gate.mjs119 lines · 5.8 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
// 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); });