/** * NexterCaptcha — risk-adaptive виджет капчи. * * NexterCaptcha.render(container, { * site: 'crm', action: 'register', * onVerified: ({token, mode, riskScore}) => { ... }, * }); * * Низкий риск → сервер сразу выдаёт токен (невидимая проверка). * Высокий риск → интерактивный proof-of-work (sha256 с ведущими нулями). */ (function () { 'use strict'; var STYLE = document.createElement('style'); STYLE.textContent = [ '.nc-box{display:flex;align-items:center;gap:14px;width:100%;padding:12px 0;border-top:1px solid var(--ink,#111);border-bottom:1px solid var(--ink,#111);border-radius:0;background:transparent;font:14px/1.4 var(--font,system-ui),sans-serif;color:var(--ink,#111)}', '.nc-btn{display:inline-flex;align-items:center;gap:8px;flex:none;background:var(--ink,#111);color:var(--bg,#efefec);border:none;border-radius:0;padding:10px 14px;font:600 14px var(--font,system-ui);cursor:pointer}', '.nc-btn:disabled{opacity:.45;cursor:wait}', '.nc-state{flex:1;min-width:0}', '.nc-status{font-weight:600;letter-spacing:-.02em}', '.nc-sub{color:var(--muted,#5f5f5c);font-size:12px;margin-top:2px}', '.nc-ok{color:var(--ok,#0d6b3d)}.nc-err{color:var(--bad,#9f1d14)}.nc-warn{color:var(--warn,#8a5a00)}', '.nc-powbar{height:2px;border-radius:0;background:var(--line-soft,rgba(17,17,17,.14));overflow:hidden;margin-top:8px}', '.nc-powfill{height:100%;width:0;background:var(--ink,#111);transition:width .2s}', '.nc-spin{width:14px;height:14px;border:2px solid rgba(239,239,236,.35);border-top-color:var(--bg,#efefec);border-radius:50%;animation:nc-rot .8s linear infinite;display:none}', '.nc-box.busy .nc-spin{display:inline-block}', '@keyframes nc-rot{to{transform:rotate(360deg)}}' ].join('\n'); document.head.appendChild(STYLE); function sha256Hex(text) { var te = new TextEncoder(); return crypto.subtle.digest('SHA-256', te.encode(text)).then(function (d) { return Array.prototype.map.call(new Uint8Array(d), function (b) { return b.toString(16).padStart(2, '0'); }).join(''); }); } function leadingZeroBits(hex) { var bits = 0; for (var i = 0; i < hex.length; i++) { var v = parseInt(hex[i], 16); if (v === 0) { bits += 4; continue; } if (v < 2) bits += 3; else if (v < 4) bits += 2; else if (v < 8) bits += 1; break; } return bits; } // PoW-решатель: перебор с прогрессом, ~50k хэшей/с в WebCrypto. function solvePow(nonce, difficulty, onProgress) { var batch = 400; var i = 0; return new Promise(function (resolve, reject) { function step() { var chain = Promise.resolve(); var start = i; for (var j = 0; j < batch; j++, i++) { (function (k) { chain = chain.then(function () { return sha256Hex(nonce + k).then(function (h) { if (leadingZeroBits(h) >= difficulty) throw { solved: String(k) }; }); }); })(i); } chain.then(function () { onProgress(i); if (i > 5e6) return reject(new Error('pow: exceeded attempts')); setTimeout(step, 0); }, function (early) { if (early && early.solved) resolve(early.solved); else reject(early); }); void start; } step(); }); } function render(container, opts) { opts = opts || {}; var box = document.createElement('div'); box.className = 'nc-box'; box.innerHTML = '' + '
Защищённое действие
' + '
risk-adaptive проверка NexterFingerprint
' + '
'; container.appendChild(box); var btn = box.querySelector('.nc-btn'); var status = box.querySelector('.nc-status'); var sub = box.querySelector('.nc-sub'); var powBar = box.querySelector('.nc-powbar'); var powFill = box.querySelector('.nc-powfill'); btn.addEventListener('click', function () { btn.disabled = true; box.classList.add('busy'); status.textContent = 'Проверяю окружение…'; status.className = 'nc-status'; sub.textContent = 'сбор сигналов в трёх контекстах'; if (typeof NexterFingerprint === 'undefined') { fail('nexter.js не загружен'); return; } var body = { encrypted: null, site: opts.site || 'default', action: opts.action || 'submit' }; // Сбор + отправка: берём plaintext-режим scan, но подменяем тело // на {payload, site, action} для /challenge. NexterFingerprint.scan({ endpoint: '/api/v1/challenge', behaviorMs: opts.behaviorMs || 1500, encryptWrap: function (encrypted) { body.encrypted = encrypted; return body; } }).then(function (resp) { var d = resp.data; if (!d) throw new Error(resp.error && resp.error.message || 'challenge failed'); if (d.mode === 'pass') { succeed(d, 'Проверено без капчи', 'risk ' + d.risk_score + ' · токен выдан'); return; } // Интерактивный PoW. status.textContent = 'Подтвердите, что вы человек'; status.className = 'nc-status nc-warn'; sub.textContent = 'proof-of-work: ' + d.challenge.difficulty + ' бит (' + d.challenge.algorithm + ')'; powBar.hidden = false; var t0 = performance.now(); solvePow(d.challenge.nonce, d.challenge.difficulty, function (n) { powFill.style.width = Math.min(96, (n / 120000) * 100) + '%'; }).then(function (suffix) { powFill.style.width = '100%'; sub.textContent = 'решено за ' + ((performance.now() - t0) / 1000).toFixed(1) + ' с'; return fetch('/api/v1/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: d.token, solution: { suffix: suffix } }) }).then(function (r) { return r.json(); }); }).then(function (v) { if (v.data && v.data.verified) { succeed(d, 'Проверено', 'капча пройдена · risk был ' + d.risk_score); } else { fail((v.error && v.error.message) || 'verification failed'); } }).catch(function (e) { fail(String(e && e.message || e)); }); }).catch(function (e) { fail(String(e && e.message || e)); }); function succeed(d, title, subText) { box.classList.remove('busy'); status.textContent = '✓ ' + title; status.className = 'nc-status nc-ok'; sub.textContent = subText; if (opts.onVerified) opts.onVerified({ token: d.token, mode: d.mode, riskScore: d.risk_score }); } function fail(msg) { box.classList.remove('busy'); btn.disabled = false; status.textContent = '✗ ' + msg; status.className = 'nc-status nc-err'; sub.textContent = 'можно повторить'; if (opts.onFail) opts.onFail(msg); } }); return { box: box }; } window.NexterCaptcha = { render: render, solvePow: solvePow, leadingZeroBits: leadingZeroBits }; })();