burterm/internal/sequencer/fips.go
2026-09-14 10:55:07 +03:00

224 lines
7.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package sequencer анализирует случайность набора токенов (сессионных
// ID, CSRF-токенов и т.п.) — аналог вкладки Sequencer в Burp. Реализует
// классический набор статистических тестов FIPS 140-2 (monobit, poker,
// runs, long run), исторически применявшийся именно для этой задачи.
//
// Тесты определены строго для блоков по 20 000 бит: входные сэмплы
// склеиваются в один битовый поток и режутся на такие блоки, остаток
// короче 20 000 бит отбрасывается. Это значит, что для содержательного
// результата нужно ощутимое количество токенов — AnalyzeConcatenated
// возвращает понятную ошибку, если данных не хватает даже на один блок.
package sequencer
import (
"encoding/hex"
"fmt"
"strings"
)
const fipsBlockBits = 20000
// runInterval — допустимый диапазон количества серий данной длины
// для теста Runs, согласно таблице FIPS 140-2.
type runInterval struct{ min, max int }
// runIntervals: длины серий 1..5 — точное совпадение по таблице,
// 6 используется как корзина "6 и длиннее" (в спецификации у неё тот же
// допустимый интервал, что и у длины 5).
var runIntervals = map[int]runInterval{
1: {2343, 2657},
2: {1135, 1365},
3: {542, 708},
4: {251, 373},
5: {111, 201},
6: {111, 201},
}
// BlockResult — результат всех четырёх тестов на одном 20 000-битном блоке.
type BlockResult struct {
MonobitOnes int
MonobitPass bool
PokerX float64
PokerPass bool
ZeroRuns map[int]int
OneRuns map[int]int
RunsPass bool
LongRunMax int
LongRunPass bool
}
// AllPass — блок считается прошедшим анализ, если все четыре теста
// прошли. Один упавший тест не обязательно означает, что генератор
// токенов плох — но чем больше блоков не проходят, тем тревожнее.
func (r BlockResult) AllPass() bool {
return r.MonobitPass && r.PokerPass && r.RunsPass && r.LongRunPass
}
// ParseSample превращает одну строку ввода в байты для анализа: если
// строка целиком выглядит как чётная hex-строка (частый формат для
// сессионных токенов), она декодируется как hex; иначе берутся сырые
// байты UTF-8 самой строки.
func ParseSample(line string) []byte {
line = strings.TrimSpace(line)
if looksHex(line) {
if b, err := hex.DecodeString(line); err == nil {
return b
}
}
return []byte(line)
}
func looksHex(s string) bool {
if s == "" || len(s)%2 != 0 {
return false
}
for i := 0; i < len(s); i++ {
c := s[i]
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
return false
}
}
return true
}
// AnalyzeConcatenated склеивает все сэмплы в один битовый поток, режет
// на блоки по 20 000 бит и прогоняет каждый через все четыре теста.
// bitsAvailable — сколько бит суммарно было в сэмплах (для сообщения
// пользователю, сколько ещё нужно добавить, если блоков получилось 0).
func AnalyzeConcatenated(samples [][]byte) (results []BlockResult, blocksUsed int, bitsAvailable int, err error) {
var all []byte
for _, s := range samples {
all = append(all, s...)
}
bits := bytesToBits(all)
bitsAvailable = len(bits)
blocksUsed = bitsAvailable / fipsBlockBits
if blocksUsed == 0 {
return nil, 0, bitsAvailable, fmt.Errorf(
"нужно минимум %d бит для одного FIPS-блока, есть %d — добавь ещё сэмплов",
fipsBlockBits, bitsAvailable,
)
}
results = make([]BlockResult, 0, blocksUsed)
for b := 0; b < blocksUsed; b++ {
block := bits[b*fipsBlockBits : (b+1)*fipsBlockBits]
ones, monoOK := monobitTest(block)
x, pokerOK := pokerTest(block)
zeroRuns, oneRuns, runsOK := runsTest(block)
maxRun, longOK := longRunTest(block)
results = append(results, BlockResult{
MonobitOnes: ones, MonobitPass: monoOK,
PokerX: x, PokerPass: pokerOK,
ZeroRuns: zeroRuns, OneRuns: oneRuns, RunsPass: runsOK,
LongRunMax: maxRun, LongRunPass: longOK,
})
}
return results, blocksUsed, bitsAvailable, nil
}
// bytesToBits разворачивает байты в отдельные биты, старший бит первым.
func bytesToBits(data []byte) []byte {
bits := make([]byte, len(data)*8)
for i, b := range data {
for j := 0; j < 8; j++ {
bits[i*8+j] = (b >> uint(7-j)) & 1
}
}
return bits
}
// monobitTest — доля единиц в блоке должна быть близка к половине.
// block должен быть длиной ровно fipsBlockBits.
func monobitTest(block []byte) (ones int, pass bool) {
for _, b := range block {
ones += int(b)
}
return ones, ones > 9725 && ones < 10275
}
// pokerTest делит блок на 5000 непересекающихся 4-битных сегментов,
// считает распределение по 16 возможным значениям и проверяет
// статистику X на попадание в допустимый интервал по таблице FIPS 140-2.
func pokerTest(block []byte) (x float64, pass bool) {
var freq [16]int
for i := 0; i < 5000; i++ {
v := 0
for j := 0; j < 4; j++ {
v = (v << 1) | int(block[i*4+j])
}
freq[v]++
}
sumSq := 0.0
for _, f := range freq {
sumSq += float64(f) * float64(f)
}
x = (16.0/5000.0)*sumSq - 5000.0
return x, x > 2.16 && x < 46.17
}
// runsTest считает количество серий (подряд идущих одинаковых бит)
// каждой длины отдельно для серий из нулей и из единиц, и сверяет
// с допустимыми интервалами runIntervals. Длины 6 и больше — общая
// корзина.
func runsTest(block []byte) (zeroRuns, oneRuns map[int]int, pass bool) {
zeroRuns = map[int]int{}
oneRuns = map[int]int{}
i := 0
n := len(block)
for i < n {
j := i
for j < n && block[j] == block[i] {
j++
}
length := j - i
bucket := length
if bucket > 6 {
bucket = 6
}
if block[i] == 0 {
zeroRuns[bucket]++
} else {
oneRuns[bucket]++
}
i = j
}
pass = true
for length := 1; length <= 6; length++ {
interval := runIntervals[length]
if zeroRuns[length] < interval.min || zeroRuns[length] > interval.max {
pass = false
}
if oneRuns[length] < interval.min || oneRuns[length] > interval.max {
pass = false
}
}
return zeroRuns, oneRuns, pass
}
// longRunTest — блок не должен содержать серию длиной 26 бит и более
// (ни из нулей, ни из единиц).
func longRunTest(block []byte) (maxRun int, pass bool) {
i := 0
n := len(block)
for i < n {
j := i
for j < n && block[j] == block[i] {
j++
}
if length := j - i; length > maxRun {
maxRun = length
}
i = j
}
return maxRun, maxRun < 26
}