275 lines
7.0 KiB
Go
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
|
|
}
|