はじめに
作ったの自体は結構前ですが、せっかく作ったものなので人の目に見えるところに。。。
基本的には下記を参考にChrome以外で同じChromium系であるEdgeとOperaにも対応、かつWPFで表示したりExcel出力できるようになっただけです。
他のブラウザでもChromiumベースであればSQLiteファイルとLocal Stateへのパスを設定すれば見れるようになります。
なんか当時参考にさせていただいたロジックから変えたような気がしつつも思い出せず。。。
使用ライブラリ
画面周り
- ClosedXML
- ModernWpfUI
- Prism.Unity
- ReactiveProperty.WPF
ブラウザ資格情報復号化周り
- BouncyCastle.Cryptography
- Newtonsoft.Json
- System.Data.SQLite
- System.Security.Cryptography.ProtectedData
画面イメージ
※ 値は消してます
対象ブラウザはChrome、Opera、Edgeでドロップダウンリストにはユーザデータが存在するブラウザのみ表示します。
また、プロファイルもDefaultプロファイルとそれ以外の存在しているプロファイルを一覧表示します。
GoogleChromeSettings.cs
namespace VL.BrowserCredentials.Browsers
{
[BrowserInfo("GoogleChrome", "Google Chrome")]
internal class GoogleChromeSettings : AbstractBrowserSettings
{
internal override string BrowserCommonPath => @"Google\Chrome\User Data";
internal override SettingDataType LoginDataType => SettingDataType.SQLite;
internal override string DecryptionKeyPath => $@"{this.GetBrowserCommonPathFromRoot()}\Local State";
internal override SettingDataType DecryptionKeyType => SettingDataType.JSON;
internal override string ProcessName => "chrome";
internal override string LoginDataPath(string profile) => $@"{this.GetBrowserCommonPathFromRoot()}\{profile}\Login Data";
internal override IDictionary<string, string> GetProfiles() => this.GetDefaultAndNumberingProfiles();
protected override UserFolderType FolderType => UserFolderType.Local;
}
}
AbstractBrowserSettings.cs
protected IDictionary<string, string> GetDefaultAndNumberingProfiles()
{
var paths = new Dictionary<string, string>();
foreach (var p in this.GetBrowserCommonPathFromRoot().GetSubDirectoriesPathOfCurrent().Where(p => this.IsDefaultAndNumberingProfile(p)).Select(p => p.GetFileName()))
{
if (p is not null) paths.Add(p, p);
}
return paths;
}
private bool IsDefaultAndNumberingProfile(string path)
{
var directoryName = path.GetFileName();
if (directoryName is null) return false;
return directoryName.StartsWith("Profile ") || directoryName == "Default";
}
UsableBrowserProvider.cs
private static readonly IDictionary<string, AbstractBrowserSettings> _NameSettingDic = new Dictionary<string, AbstractBrowserSettings>();
public static IDictionary<string, string> FindUsableBrowsers()
{
var usableBrowsers = new Dictionary<string, string>();
var t = typeof(BrowserSettingsFactory);
foreach (var pro in t.GetProperties(BindingFlags.Static | BindingFlags.NonPublic))
{
var val = pro.GetValue(t, null);
if (val is not null)
{
var setting = (AbstractBrowserSettings)val;
if (setting.ExistsBrowserCommonPath())
{
var proType = val.GetType();
var attribute = proType.GetCustomAttribute<BrowserInfo>();
if (attribute is not null)
{
usableBrowsers.Add(attribute.BrowserKey, attribute.BrowserName);
_NameSettingDic.Add(attribute.BrowserKey, setting);
}
}
}
}
return usableBrowsers;
}
public static IDictionary<string, string> FindProfiles(string browserName)
{
var setting = _NameSettingDic[browserName];
return setting.GetProfiles();
}
例外処理
ブラウザが正しくパスワードを保存できていない?ことがたまにあるようでまれに複合できずにInvalidCipherTextExceptionが発生します。
複合できなかったレコードについては飛ばすようにしています。
SQLite見れるツールで直接loginsテーブルの中を見てみると分かりますが、ユーザ名もパスワードも片方しか入ってないレコードやどちらも入っていないレコード等、色んなパターンがあります。
LoginsReader.cs
[SupportedOSPlatform("windows")]
internal static IEnumerable<Logins>? SelectLoginsRecordsFromChromium(AbstractBrowserSettings settings, string? profile)
{
if (!settings.ExistsDecryptionKey()) return null;
var profileName = (profile is null ? string.Empty : profile);
if (!settings.ExistsLoginData(profileName)) return null;
var key = DpapiKeyLoader.LoadKey(settings);
if (key is null) return null;
settings.ProcessName.KillSameNameProcesses();
using var con = new SQLiteConnection($"Data Source={settings.LoginDataPath(profileName)};");
try
{
con.Open();
using var command = con.CreateCommand();
command.CommandText = @"
SELECT origin_url
, username_value
, password_value
FROM logins
WHERE username_value <> ''
AND password_value <> ''
ORDER BY origin_url;";
using var adapter = new SQLiteDataAdapter(command);
using var table = new DataTable();
_ = adapter.Fill(table);
var records = table.ToList<Logins>();
foreach (var record in records)
{
if (record.PasswordValue is null) continue;
(var initialVector, var cipherText) = PasswordCipher.SeparateBinaryValues(record.PasswordValue);
try
{
record.Password = PasswordCipher.DecryptPassword(key, initialVector, cipherText);
}
catch (InvalidCipherTextException)
{
// 複合に失敗したレコードは飛ばす
}
}
return records;
}
finally
{
if (con.State == ConnectionState.Open) con.Close();
}
}
}
GitHub