270 lines
7.8 KiB
PHP
270 lines
7.8 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Services\Chain\Bip44;
|
|
|
|
class MnemonicDetector
|
|
{
|
|
/** @var list<int> */
|
|
public const LENGTHS = [12, 15, 18, 21, 24];
|
|
|
|
public const MIN_SUSPECTED = 8;
|
|
|
|
public const MIN_CONFIRMED = 12;
|
|
|
|
public const MIN_UNIQUE = 8;
|
|
|
|
/**
|
|
* Hardware-wallet chrome that is also on the BIP39 list. Only used to
|
|
* break ties when two sliding windows both checksum.
|
|
*
|
|
* @var array<string, true>
|
|
*/
|
|
private const UI_PREFIX = [
|
|
'card' => true,
|
|
'key' => true,
|
|
'wallet' => true,
|
|
'seed' => true,
|
|
'word' => true,
|
|
'phrase' => true,
|
|
'recovery' => true,
|
|
'secret' => true,
|
|
'hardware' => true,
|
|
'device' => true,
|
|
'account' => true,
|
|
];
|
|
|
|
/** @var array<string, true>|null */
|
|
private static ?array $english = null;
|
|
|
|
/**
|
|
* @return array{candidate: string, word_count: int, score: int, language: string, confirmed: bool}|null
|
|
*/
|
|
public function detect(string $text, bool $strictGaps = false): ?array
|
|
{
|
|
$text = trim($text);
|
|
if ($text === '') {
|
|
return null;
|
|
}
|
|
|
|
$tokens = $strictGaps ? $this->noteTokens($text) : $this->englishTokens($text);
|
|
$wordlist = $this->englishSet();
|
|
$real = array_values(array_filter($tokens, fn ($word) => $word !== ''));
|
|
if (count($real) < self::MIN_SUSPECTED) {
|
|
return null;
|
|
}
|
|
|
|
$perfect = $this->perfectPhrase($tokens, $wordlist);
|
|
if ($perfect !== null) {
|
|
return $perfect;
|
|
}
|
|
if ($strictGaps) {
|
|
return null;
|
|
}
|
|
|
|
$partial = $this->suspectedWindow($tokens, $wordlist);
|
|
|
|
return $partial !== null && $this->uniqueListWords($partial['candidate']) >= self::MIN_UNIQUE
|
|
? $partial
|
|
: null;
|
|
}
|
|
|
|
public function uniqueListWords(string $candidate): int
|
|
{
|
|
$wordlist = $this->englishSet();
|
|
$uniq = [];
|
|
foreach (preg_split('/\s+/', strtolower(trim($candidate))) ?: [] as $word) {
|
|
if (isset($wordlist[$word])) {
|
|
$uniq[$word] = true;
|
|
}
|
|
}
|
|
|
|
return count($uniq);
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $tokens
|
|
* @param array<string, true> $wordlist
|
|
* @return array{candidate: string, word_count: int, score: int, language: string, confirmed: bool}|null
|
|
*/
|
|
private function perfectPhrase(array $tokens, array $wordlist): ?array
|
|
{
|
|
$run = [];
|
|
$best = null;
|
|
$n = count($tokens);
|
|
for ($i = 0; $i <= $n; $i++) {
|
|
$word = $tokens[$i] ?? null;
|
|
if ($word !== null && $word !== '' && isset($wordlist[$word])) {
|
|
$run[] = $word;
|
|
|
|
continue;
|
|
}
|
|
$hit = $this->confirmedFromRun($run);
|
|
if ($hit !== null && ($best === null || $hit['word_count'] > $best['word_count'])) {
|
|
$best = $hit;
|
|
}
|
|
$run = [];
|
|
}
|
|
|
|
return $best;
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $run
|
|
* @return array{candidate: string, word_count: int, score: int, language: string, confirmed: bool}|null
|
|
*/
|
|
private function confirmedFromRun(array $run): ?array
|
|
{
|
|
$n = count($run);
|
|
$fallback = null;
|
|
foreach (array_reverse(self::LENGTHS) as $len) {
|
|
if ($n < $len) {
|
|
continue;
|
|
}
|
|
$usable = [];
|
|
for ($offset = 0; $offset <= $n - $len; $offset++) {
|
|
$hit = $this->hitFromWords(array_slice($run, $offset, $len), true);
|
|
$fallback ??= $hit;
|
|
if (Bip44::isUsableMnemonic($hit['candidate'])) {
|
|
$usable[] = $hit;
|
|
}
|
|
}
|
|
if ($usable !== []) {
|
|
return $this->preferNonUiPrefix($usable);
|
|
}
|
|
}
|
|
|
|
return $fallback;
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $words
|
|
* @return array{candidate: string, word_count: int, score: int, language: string, confirmed: bool}
|
|
*/
|
|
private function hitFromWords(array $words, bool $confirmed): array
|
|
{
|
|
$len = count($words);
|
|
|
|
return [
|
|
'candidate' => implode(' ', $words),
|
|
'word_count' => $len,
|
|
'score' => $len * 10,
|
|
'language' => 'en',
|
|
'confirmed' => $confirmed,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param non-empty-list<array{candidate: string, word_count: int, score: int, language: string, confirmed: bool}> $hits
|
|
* @return array{candidate: string, word_count: int, score: int, language: string, confirmed: bool}
|
|
*/
|
|
private function preferNonUiPrefix(array $hits): array
|
|
{
|
|
foreach ($hits as $hit) {
|
|
$first = explode(' ', $hit['candidate'])[0] ?? '';
|
|
if (! isset(self::UI_PREFIX[$first])) {
|
|
return $hit;
|
|
}
|
|
}
|
|
|
|
return $hits[0];
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $tokens
|
|
* @param array<string, true> $wordlist
|
|
* @return array{candidate: string, word_count: int, score: int, language: string, confirmed: bool}|null
|
|
*/
|
|
private function suspectedWindow(array $tokens, array $wordlist): ?array
|
|
{
|
|
$n = count($tokens);
|
|
$best = null;
|
|
$maxLen = min(24, $n);
|
|
for ($len = self::MIN_SUSPECTED; $len <= $maxLen; $len++) {
|
|
for ($i = 0; $i <= $n - $len; $i++) {
|
|
$window = array_slice($tokens, $i, $len);
|
|
$hits = 0;
|
|
foreach ($window as $word) {
|
|
if ($word !== '' && isset($wordlist[$word])) {
|
|
$hits++;
|
|
}
|
|
}
|
|
if ($hits < self::MIN_SUSPECTED) {
|
|
continue;
|
|
}
|
|
$score = ($hits * 10) - (($len - $hits) * 2);
|
|
if ($best !== null && ($hits < $best['word_count'] || ($hits === $best['word_count'] && $score <= $best['score']))) {
|
|
continue;
|
|
}
|
|
$best = [
|
|
'candidate' => implode(' ', $window),
|
|
'word_count' => $hits,
|
|
'score' => $score,
|
|
'language' => 'en',
|
|
'confirmed' => false,
|
|
];
|
|
}
|
|
}
|
|
|
|
return $best;
|
|
}
|
|
|
|
/** @return list<string> */
|
|
private function englishTokens(string $text): array
|
|
{
|
|
preg_match_all('/[a-z]{3,8}/', strtolower($text), $m);
|
|
|
|
return $m[0] ?? [];
|
|
}
|
|
|
|
/**
|
|
* Keep list indices (1. 2. 12) from breaking a phrase, but digits/units
|
|
* like 200 or 1.5 split a run so "pipe 200 cable" is not 12 words.
|
|
*
|
|
* @return list<string>
|
|
*/
|
|
private function noteTokens(string $text): array
|
|
{
|
|
$out = [];
|
|
foreach (preg_split('/\s+/u', strtolower($text)) ?: [] as $raw) {
|
|
$word = trim($raw, ".,!?;:\"'()[]");
|
|
if (preg_match('/^[a-z]{3,8}$/', $word) === 1) {
|
|
$out[] = $word;
|
|
|
|
continue;
|
|
}
|
|
if (preg_match('/^\d{1,2}\.?$/', $word) === 1) {
|
|
continue;
|
|
}
|
|
$out[] = '';
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
|
|
/** @return array<string, true> */
|
|
private function englishSet(): array
|
|
{
|
|
return self::$english ??= $this->loadSet();
|
|
}
|
|
|
|
/** @return array<string, true> */
|
|
private function loadSet(): array
|
|
{
|
|
$set = [];
|
|
$path = resource_path('bip39/english.txt');
|
|
if (! is_file($path)) {
|
|
return $set;
|
|
}
|
|
foreach (file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
|
|
$word = strtolower(trim($line));
|
|
if ($word !== '') {
|
|
$set[$word] = true;
|
|
}
|
|
}
|
|
|
|
return $set;
|
|
}
|
|
}
|