Ключ: датчик случайных чисел Валидаты инициализируется из мастера — человек водит мышью в браузере

На сервере без экрана окно Валидаты «Пожалуйста, двигайте мышью» никто не видел, ключ в тихом режиме
падал с 0x80090022 (ИШ: «Не найден ключ ЭП (E0700009)»), пересборка справочника — тоже.
bj-validata-rng: тихая проверка датчика (testcsp -silent -random), сеанс на виртуальном экране,
движения из поля мастера повторяются в окне Валидаты (XTEST), прогресс — по полосе её окна.
Установщик ключа проверяет датчик до записи на флешку. zpki.ini: Count = числу профилей, блоки подряд.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
This commit is contained in:
zuevav
2026-10-06 19:53:18 +03:00
parent d9b8af2c0a
commit cf1da4a0a9
7 changed files with 559 additions and 7 deletions
+1
View File
@@ -136,6 +136,7 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service
// Пошаговый мастер установки ключа Валидаты на флешку.
h.registerKeyWizard(mux)
h.registerValidataRNG(mux)
}
// renderNews — GET /admin/news.
+198
View File
@@ -0,0 +1,198 @@
package lkgateway
// Датчик случайных чисел СКЗИ Валидата (06.10.2026). Перед первой работой с ключом Валидата собирает случайность
// от движений мыши человека («Пожалуйста, двигайте мышью…»); дальше состояние датчика хранит служба ключей, и
// это делается один раз для сервера. На сервере без экрана окна никто не видит, и всё, что работает с ключом
// в «тихом» режиме — ИШ, пересборка справочника, проверка подписи, — падает с 0x80090022, а ИШ пишет «Не найден
// ключ ЭП (E0700009)», хотя флешка с ключом на месте (так было на чистом сервере .29).
//
// Окно Валидаты открывает root-сеанс bj-validata-rng на виртуальном экране; человек водит мышью по полю в мастере,
// браузер шлёт движения пачками сюда, мы кладём их в /var/lib/bj/rng/in — сеанс повторяет их в окне Валидаты и
// пишет прогресс по полосе самого окна в /var/lib/bj/rng/status.json. Случайность не подменяем ничем своим.
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
var rngDir = "/var/lib/bj/rng"
// rngReady — датчик уже инициализирован (запоминаем: обратно в «не готов» он сам не возвращается).
var rngReady struct {
sync.Mutex
ok bool
}
// rngView — ответ страницы: готов ли датчик и как идёт сеанс.
type rngView struct {
OK bool `json:"ok"`
Ready bool `json:"ready"`
State string `json:"state,omitempty"` // starting | running | done | failed | cancelled
Progress float64 `json:"progress"`
Window bool `json:"window,omitempty"` // окно Валидаты открылось — движения уже идут в него
Error string `json:"error,omitempty"`
}
// rngSession — что пишет сеанс в status.json (at — время записи, unix).
type rngSession struct {
State string `json:"state"`
Progress float64 `json:"progress"`
Window bool `json:"window"`
Error string `json:"error"`
At int64 `json:"at"`
}
func rngReadSession() (rngSession, time.Duration) {
var s rngSession
b, err := os.ReadFile(filepath.Join(rngDir, "status.json"))
if err != nil || json.Unmarshal(b, &s) != nil {
return rngSession{}, 0
}
return s, time.Since(time.Unix(s.At, 0))
}
// rngLive — сеанс идёт (сеанс обновляет файл раз в полсекунды; «starting» пишется до запуска службы).
func rngLive(s rngSession, age time.Duration) bool {
return (s.State == "running" && age < 15*time.Second) || (s.State == "starting" && age < 60*time.Second)
}
// rngCheck — состояние датчика: идущий сеанс — из его файла, иначе тихая проверка у root-воркера.
func rngCheck(ctx context.Context) rngView {
s, age := rngReadSession()
if rngLive(s, age) {
return rngView{OK: true, State: s.State, Progress: s.Progress, Window: s.Window}
}
rngReady.Lock()
ready := rngReady.ok
rngReady.Unlock()
if !ready {
res, err := runKeymediaOp(ctx, "rng-status", nil)
if err != nil {
return rngView{Error: "проверка датчика не удалась: " + errStr(err)}
}
ready = res["ready"] == true
if ready {
rngReady.Lock()
rngReady.ok = true
rngReady.Unlock()
}
}
v := rngView{OK: true, Ready: ready}
if age < 10*time.Minute { // итог недавнего сеанса — показать, чем кончился
switch {
case ready && s.State == "done":
v.State, v.Progress = "done", 1
case !ready && (s.State == "failed" || s.State == "cancelled"):
v.State, v.Error = s.State, s.Error
}
}
return v
}
func (h *setupHandlers) registerValidataRNG(mux *http.ServeMux) {
mux.HandleFunc("/admin/setup/rng/status", h.rngStatus)
mux.HandleFunc("/admin/setup/rng/start", h.rngStart)
mux.HandleFunc("/admin/setup/rng/moves", h.rngMoves)
mux.HandleFunc("/admin/setup/rng/cancel", h.rngCancel)
}
func rngJSON(w http.ResponseWriter, v rngView) { rngJSONCode(w, http.StatusOK, v) }
func rngJSONCode(w http.ResponseWriter, code int, v rngView) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
// GET /admin/setup/rng/status
func (h *setupHandlers) rngStatus(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second)
defer cancel()
rngJSON(w, rngCheck(ctx))
}
// POST /admin/setup/rng/start — открыть окно датчика Валидаты (уже готов — сразу ready).
func (h *setupHandlers) rngStart(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second)
defer cancel()
res, err := runKeymediaOp(ctx, "rng-start", nil)
if err != nil {
rngJSON(w, rngView{Error: "окно датчика не открылось: " + errStr(err)})
return
}
if res["ready"] == true {
rngReady.Lock()
rngReady.ok = true
rngReady.Unlock()
rngJSON(w, rngView{OK: true, Ready: true})
return
}
rngJSON(w, rngView{OK: true, State: "starting"})
}
// rngMovesMax — движений в одной пачке (браузер шлёт раз в четверть секунды, мышь — до 1000 событий в секунду).
const rngMovesMax = 1500
// POST /admin/setup/rng/moves — пачка движений мыши из поля мастера: {"m":[[x,y,dt],…]}, x и y — 0…1000
// по полю, dt — миллисекунды от предыдущего движения. Кладём файлом сеансу; в ответ — прогресс.
func (h *setupHandlers) rngMoves(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
s, age := rngReadSession()
if !rngLive(s, age) {
rngJSONCode(w, http.StatusConflict, rngCheck(r.Context())) // сеанса нет: браузер покажет, что дальше
return
}
var in struct {
M [][3]int `json:"m"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&in); err != nil {
http.Error(w, "движения: "+err.Error(), http.StatusBadRequest)
return
}
if len(in.M) > rngMovesMax {
in.M = in.M[:rngMovesMax]
}
var b strings.Builder
for _, m := range in.M {
x, y, dt := m[0], m[1], m[2]
if x < 0 || x > 1000 || y < 0 || y > 1000 || dt < 0 || dt > 1000 {
continue
}
fmt.Fprintf(&b, "%d %d %d\n", x, y, dt)
}
if b.Len() > 0 {
dir := filepath.Join(rngDir, "in")
name := filepath.Join(dir, fmt.Sprintf("%019d.mv", time.Now().UnixNano()))
if err := os.WriteFile(name+".tmp", []byte(b.String()), 0o600); err == nil {
_ = os.Rename(name+".tmp", name)
}
}
rngJSON(w, rngView{OK: true, State: s.State, Progress: s.Progress, Window: s.Window})
}
// POST /admin/setup/rng/cancel
func (h *setupHandlers) rngCancel(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
_, _ = runKeymediaOp(ctx, "rng-cancel", nil)
rngJSON(w, rngCheck(ctx))
}
+54
View File
@@ -0,0 +1,54 @@
package lkgateway
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// Движения из поля мастера доходят до сеанса датчика файлом — только допустимые, и только пока сеанс идёт.
func TestRNGMoves(t *testing.T) {
old := rngDir
rngDir = t.TempDir()
defer func() { rngDir = old }()
rngReady.Lock()
rngReady.ok = true // без обращения к root-воркеру
rngReady.Unlock()
defer func() { rngReady.Lock(); rngReady.ok = false; rngReady.Unlock() }()
_ = os.MkdirAll(filepath.Join(rngDir, "in"), 0o700)
h := &setupHandlers{}
post := func() *httptest.ResponseRecorder {
w := httptest.NewRecorder()
h.rngMoves(w, httptest.NewRequest(http.MethodPost, "/admin/setup/rng/moves", strings.NewReader(`{"m":[[10,20,0],[1001,5,3],[500,500,16],[-1,0,0]]}`)))
return w
}
if w := post(); w.Code != http.StatusConflict {
t.Fatalf("без сеанса движения не принимаются: %d", w.Code)
}
st := fmt.Sprintf(`{"state":"running","progress":0.42,"window":true,"at":%d}`, time.Now().Unix())
_ = os.WriteFile(filepath.Join(rngDir, "status.json"), []byte(st), 0o644)
w := post()
var v rngView
if err := json.Unmarshal(w.Body.Bytes(), &v); err != nil || w.Code != 200 || v.Progress != 0.42 || !v.Window {
t.Fatalf("ответ: %d %s", w.Code, w.Body.String())
}
files, _ := filepath.Glob(filepath.Join(rngDir, "in", "*.mv"))
if len(files) != 1 {
t.Fatalf("файлов движений: %d", len(files))
}
b, _ := os.ReadFile(files[0])
if string(b) != "10 20 0\n500 500 16\n" {
t.Fatalf("движения: %q", b)
}
// сеанс кончился (файл давно не обновлялся) — датчик готов, итог «done» показывается
_ = os.WriteFile(filepath.Join(rngDir, "status.json"), []byte(fmt.Sprintf(`{"state":"done","progress":1,"at":%d}`, time.Now().Unix())), 0o644)
if v := rngCheck(t.Context()); !v.Ready || v.State != "done" {
t.Fatalf("после сеанса: %+v", v)
}
}
@@ -14,8 +14,73 @@
.kw-tile small{display:block;color:var(--muted);font-size:13px;line-height:1.4;margin-top:3px}
.kw-panel{margin-top:16px}
.kw-drive{display:flex;gap:10px;align-items:flex-start;padding:10px;border:1px solid var(--border,#ddd);border-radius:8px;cursor:pointer;margin:0}
.kw-pad{height:200px;margin-top:12px;border:2px dashed var(--border);border-radius:12px;display:flex;align-items:center;justify-content:center;text-align:center;padding:0 16px;color:var(--muted);user-select:none;-webkit-user-select:none;touch-action:none;cursor:default}
.kw-pad.on{border-color:var(--accent);background:var(--accent-weak);color:var(--accent);cursor:crosshair}
.kw-bar{height:8px;margin-top:12px;border-radius:4px;background:var(--surface-2,#eee);overflow:hidden}
.kw-bar i{display:block;height:100%;width:0;background:var(--accent);transition:width .3s}
.kw-rng.ok .kw-bar i{background:var(--ok)}
.kw-rng-row{display:flex;gap:12px;align-items:center;justify-content:space-between;flex-wrap:wrap;margin-top:10px}
.kw-locked{opacity:.45;pointer-events:none;filter:grayscale(.3)}
@media (max-width:600px){.kw-tiles{grid-template-columns:minmax(0,1fr)}}
</style>
{{/* ===== Датчик случайных чисел Валидаты: один раз для сервера, до первой работы с ключом (validatarng.go) ===== */}}
{{if not $s.Done}}
<div class="card kw-rng" id="kw-rng" hidden>
<h2>Датчик случайных чисел Валидаты</h2>
<p class="muted" style="margin:0">Перед первой работой с ключом СКЗИ Валидата набирает случайность от движений мыши — на рабочем месте она показывает окно «Пожалуйста, двигайте мышью». У сервера экрана нет: окно Валидаты открыто на сервере, а мышью водите вы — здесь, bj передаёт ваши движения в это окно. Делается один раз для сервера, около минуты.</p>
<div class="kw-pad" id="kw-pad">Нажмите «Начать» и водите мышью по этому полю, пока полоса не заполнится</div>
<div class="kw-bar"><i id="kw-rng-bar"></i></div>
<div class="kw-rng-row"><span id="kw-rng-msg" class="muted">Датчик ещё не инициализирован — без этого ключ на сервере не работает.</span>
<span style="display:flex;gap:8px"><button type="button" class="btn btn-secondary" id="kw-rng-stop" hidden>Остановить</button><button type="button" class="btn btn-ok" id="kw-rng-go">Начать</button></span></div>
</div>
<script>(function(){
var card=document.getElementById('kw-rng'), pad=document.getElementById('kw-pad'),
bar=document.getElementById('kw-rng-bar'), msg=document.getElementById('kw-rng-msg'), go=document.getElementById('kw-rng-go'), stop=document.getElementById('kw-rng-stop');
var active=false, buf=[], lastT=0, sending=false, lastSent=0, shown=false, finished=false;
var J={'Accept':'application/json'};
function lock(on){ var m=document.getElementById('kw-main'); if(m) m.classList.toggle('kw-locked', on); }
function pct(v){ return Math.round(Math.min(1,v||0)*100); }
function show(v){
if(finished) return;
if(v.ready){ lock(false); active=false;
if(shown||v.state==='done'){ card.hidden=false; card.classList.add('ok'); finished=true; bar.style.width='100%'; pad.hidden=true; go.hidden=true; stop.hidden=true;
msg.textContent='✓ Датчик готов — теперь подключите ключ ниже. Повторять это не нужно.'; }
return; }
shown=true; card.hidden=false; lock(true);
if(v.state==='running'||v.state==='starting'){
active=true; pad.classList.add('on'); go.hidden=true; stop.hidden=false; bar.style.width=pct(v.progress)+'%';
pad.textContent=v.window?'Водите мышью по полю — '+pct(v.progress)+' %':'Открываю окно Валидаты на сервере…';
msg.textContent=v.window?'Ведите мышью без остановки, пока полоса не заполнится.':'Ещё пара секунд…';
} else {
active=false; pad.classList.remove('on'); go.hidden=false; stop.hidden=true; bar.style.width='0';
pad.textContent='Нажмите «Начать» и водите мышью по этому полю, пока полоса не заполнится';
go.textContent=(v.state==='failed'||v.state==='cancelled')?'Начать заново':'Начать';
msg.textContent=v.error?('Не получилось: '+v.error):(v.ok===false?'Состояние датчика не проверено':'Датчик ещё не инициализирован — без этого ключ на сервере не работает.');
}
}
function status(){ return fetch('/admin/setup/rng/status',{headers:J,cache:'no-store'}).then(function(r){return r.json()}).then(show).catch(function(){}); }
go.addEventListener('click',function(){ go.disabled=true; msg.textContent='Открываю окно Валидаты на сервере…';
fetch('/admin/setup/rng/start',{method:'POST',headers:J,cache:'no-store'}).then(function(r){return r.json()}).then(show)
.catch(function(){ msg.textContent='Сервер не ответил — попробуйте ещё раз'; }).then(function(){ go.disabled=false; }); });
stop.addEventListener('click',function(){ active=false; buf=[];
fetch('/admin/setup/rng/cancel',{method:'POST',headers:J,cache:'no-store'}).then(function(r){return r.json()}).then(show).catch(function(){}); });
pad.addEventListener('pointermove',function(e){ if(!active) return;
var r=pad.getBoundingClientRect(), list=(e.getCoalescedEvents&&e.getCoalescedEvents().length)?e.getCoalescedEvents():[e];
list.forEach(function(ev){ var x=Math.round((ev.clientX-r.left)/r.width*1000), y=Math.round((ev.clientY-r.top)/r.height*1000), t=ev.timeStamp;
var dt=lastT?Math.round(t-lastT):0; lastT=t;
buf.push([Math.max(0,Math.min(1000,x)),Math.max(0,Math.min(1000,y)),Math.max(0,Math.min(1000,dt))]); });
});
setInterval(function(){
if(!active||sending) return;
if(buf.length){ var m=buf.splice(0,1500); sending=true; lastSent=Date.now();
fetch('/admin/setup/rng/moves',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/json'},body:JSON.stringify({m:m}),cache:'no-store'})
.then(function(r){return r.json()}).then(show).catch(function(){}).then(function(){ sending=false; });
} else if(Date.now()-lastSent>1000){ sending=true; lastSent=Date.now(); status().then(function(){ sending=false; }); }
},250);
document.addEventListener('DOMContentLoaded',status); if(document.readyState!=='loading') status();
})();</script>
{{end}}
<div id="kw-main">
{{/* ===== Лента шагов — когда путь выбран (флешка с ключом или новая флешка из архива) ===== */}}
{{if or $s.StagingID $s.Done}}
<div class="card">
@@ -174,4 +239,5 @@
r.forEach(function(x){ x.addEventListener('change', sync); }); sync();
})();</script>
{{end}}
</div>
{{end}}