From 391d541dc10e9a2e278147b5d379e9e78a6c05ef Mon Sep 17 00:00:00 2001 From: Jan Date: Thu, 25 Jun 2026 15:37:48 +0200 Subject: [PATCH] Initial Firefox secure vault --- .gitignore | 9 + Build.ps1 | 19 + FirefoxLauncher.cs | 30 + FirefoxSessionVault.ps1 | 1172 +++++++++++++++++++++++++++++++++++++++ README.md | 56 ++ 5 files changed, 1286 insertions(+) create mode 100644 .gitignore create mode 100644 Build.ps1 create mode 100644 FirefoxLauncher.cs create mode 100644 FirefoxSessionVault.ps1 create mode 100644 README.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..095d532 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.firefox-session-vault/ +.agents/ +*.lnk +*.exe +*.pdb +*.user +*.log +*.tmp +*.zip diff --git a/Build.ps1 b/Build.ps1 new file mode 100644 index 0000000..a994ff3 --- /dev/null +++ b/Build.ps1 @@ -0,0 +1,19 @@ +$ErrorActionPreference = "Stop" + +$baseDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$candidates = @( + "$env:WINDIR\Microsoft.NET\Framework64\v4.0.30319\csc.exe", + "$env:WINDIR\Microsoft.NET\Framework\v4.0.30319\csc.exe" +) + +$compiler = $candidates | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1 +if (-not $compiler) { + throw "C# compiler csc.exe wurde nicht gefunden." +} + +& $compiler /nologo /target:winexe /out:(Join-Path $baseDir "FirefoxLauncher.exe") (Join-Path $baseDir "FirefoxLauncher.cs") +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} + +& powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $baseDir "FirefoxSessionVault.ps1") -Mode Shortcut diff --git a/FirefoxLauncher.cs b/FirefoxLauncher.cs new file mode 100644 index 0000000..9050e46 --- /dev/null +++ b/FirefoxLauncher.cs @@ -0,0 +1,30 @@ +using System; +using System.Diagnostics; +using System.IO; + +internal static class FirefoxLauncher +{ + private static int Main() + { + string baseDir = AppDomain.CurrentDomain.BaseDirectory; + string scriptPath = Path.Combine(baseDir, "FirefoxSessionVault.ps1"); + + if (!File.Exists(scriptPath)) + { + return 2; + } + + var startInfo = new ProcessStartInfo + { + FileName = "powershell.exe", + Arguments = "-NoProfile -Sta -ExecutionPolicy Bypass -WindowStyle Hidden -File \"" + scriptPath + "\" -Mode Toggle", + WorkingDirectory = baseDir, + UseShellExecute = false, + CreateNoWindow = true, + WindowStyle = ProcessWindowStyle.Hidden + }; + + Process.Start(startInfo); + return 0; + } +} diff --git a/FirefoxSessionVault.ps1 b/FirefoxSessionVault.ps1 new file mode 100644 index 0000000..69f9402 --- /dev/null +++ b/FirefoxSessionVault.ps1 @@ -0,0 +1,1172 @@ +param( + [ValidateSet("Toggle", "Unlock", "Lock", "Setup", "Status", "Shortcut", "WatchClose", "Reset")] + [string]$Mode = "Toggle", + [string]$ProfilePath = "" +) + +$ErrorActionPreference = "Stop" + +$BaseDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$StateDir = Join-Path $BaseDir ".firefox-session-vault" +$ConfigPath = Join-Path $StateDir "config.json" +$VaultDir = Join-Path $StateDir "vault" +$RawVaultDir = Join-Path $StateDir "raw-vault" +$DecoyProfileDir = Join-Path $StateDir "decoy-profile" + +$ProtectedRelativePaths = @( + "cookies.sqlite", + "cookies.sqlite-wal", + "webappsstore.sqlite", + "webappsstore.sqlite-wal", + "webappsstore.sqlite-shm", + "storage\ls-archive.sqlite", + "sessionstore.jsonlz4", + "sessionstore-backups\recovery.jsonlz4", + "sessionstore-backups\recovery.baklz4", + "sessionstore-backups\previous.jsonlz4" +) + +Add-Type -AssemblyName System.Security +Add-Type -AssemblyName System.IO.Compression.FileSystem +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing + +$CharLowerAe = [char]0x00E4 +$CharLowerOe = [char]0x00F6 +$CharLowerUe = [char]0x00FC +$CharEszett = [char]0x00DF +$CharUpperOe = [char]0x00D6 + +function Ensure-StateDir { + if (-not (Test-Path -LiteralPath $StateDir)) { + New-Item -ItemType Directory -Path $StateDir | Out-Null + } + if (-not (Test-Path -LiteralPath $VaultDir)) { + New-Item -ItemType Directory -Path $VaultDir | Out-Null + } + if (-not (Test-Path -LiteralPath $RawVaultDir)) { + New-Item -ItemType Directory -Path $RawVaultDir | Out-Null + } +} + +function Convert-SecureStringToPlainText { + param([Security.SecureString]$SecureString) + + $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecureString) + try { + return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) + } + finally { + if ($bstr -ne [IntPtr]::Zero) { + [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) + } + } +} + +function New-RandomBytes { + param([int]$Length) + + $bytes = New-Object byte[] $Length + $rng = [Security.Cryptography.RandomNumberGenerator]::Create() + try { + $rng.GetBytes($bytes) + return $bytes + } + finally { + $rng.Dispose() + } +} + +function Get-DerivedBytes { + param( + [string]$Pin, + [byte[]]$Salt, + [int]$Iterations, + [int]$Length + ) + + try { + $pbkdf = [Security.Cryptography.Rfc2898DeriveBytes]::new( + $Pin, + $Salt, + $Iterations, + [Security.Cryptography.HashAlgorithmName]::SHA256 + ) + } + catch { + $pbkdf = New-Object Security.Cryptography.Rfc2898DeriveBytes($Pin, $Salt, $Iterations) + } + + try { + return $pbkdf.GetBytes($Length) + } + finally { + $pbkdf.Dispose() + } +} + +function Get-PinHash { + param( + [string]$Pin, + [byte[]]$Salt, + [int]$Iterations + ) + + return Get-DerivedBytes -Pin $Pin -Salt $Salt -Iterations $Iterations -Length 32 +} + +function Read-Config { + if (-not (Test-Path -LiteralPath $ConfigPath)) { + return $null + } + + return Get-Content -LiteralPath $ConfigPath -Raw | ConvertFrom-Json +} + +function Save-Config { + param([object]$Config) + + Ensure-StateDir + $Config | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $ConfigPath -Encoding UTF8 +} + +function Save-ConfigObject { + param([object]$Config) + + Save-Config $Config +} + +function Read-FirefoxProfilesIni { + $profilesIni = Join-Path $env:APPDATA "Mozilla\Firefox\profiles.ini" + if (-not (Test-Path -LiteralPath $profilesIni)) { + throw "Firefox profiles.ini wurde nicht gefunden. Starte Firefox einmal oder ${CharLowerUe}bergib -ProfilePath." + } + + $sections = @() + $current = $null + foreach ($line in Get-Content -LiteralPath $profilesIni) { + $trimmed = $line.Trim() + if ($trimmed -match "^\[(.+)\]$") { + if ($current) { + $sections += [pscustomobject]$current + } + $current = [ordered]@{} + $current.Section = $matches[1] + continue + } + + if ($current -and $trimmed -match "^([^=]+)=(.*)$") { + $current[$matches[1]] = $matches[2] + } + } + if ($current) { + $sections += [pscustomobject]$current + } + + return $sections +} + +function Convert-FirefoxProfilePath { + param([object]$Profile) + + if ($Profile.IsRelative -eq "1") { + return Join-Path (Join-Path $env:APPDATA "Mozilla\Firefox") $Profile.Path + } + + return $Profile.Path +} + +function Resolve-FirefoxProfilePathFromProfilesIni { + $sections = Read-FirefoxProfilesIni + + $install = $sections | + Where-Object { $_.Section -like "Install*" -and $_.Default } | + Select-Object -First 1 + + if ($install) { + $installPath = Join-Path (Join-Path $env:APPDATA "Mozilla\Firefox") $install.Default + if (Test-Path -LiteralPath $installPath) { + return $installPath + } + } + + $profile = $sections | + Where-Object { $_.Section -like "Profile*" -and $_.Default -eq "1" -and $_.Path } | + Select-Object -First 1 + + if (-not $profile) { + $profile = $sections | + Where-Object { $_.Section -like "Profile*" -and $_.Name -eq "default-release" -and $_.Path } | + Select-Object -First 1 + } + + if (-not $profile) { + $profile = $sections | + Where-Object { $_.Section -like "Profile*" -and $_.Path } | + Select-Object -First 1 + } + + if (-not $profile) { + throw "Kein Firefox-Profil gefunden." + } + + return Convert-FirefoxProfilePath $profile +} + +function Join-ByteArrays { + param([byte[][]]$Arrays) + + $length = 0 + foreach ($array in $Arrays) { + $length += $array.Length + } + + $result = New-Object byte[] $length + $offset = 0 + foreach ($array in $Arrays) { + [Array]::Copy($array, 0, $result, $offset, $array.Length) + $offset += $array.Length + } + + return $result +} + +function Protect-BytesForUser { + param([byte[]]$Bytes) + + return [Security.Cryptography.ProtectedData]::Protect( + $Bytes, + $null, + [Security.Cryptography.DataProtectionScope]::CurrentUser + ) +} + +function Unprotect-BytesForUser { + param([byte[]]$Bytes) + + return [Security.Cryptography.ProtectedData]::Unprotect( + $Bytes, + $null, + [Security.Cryptography.DataProtectionScope]::CurrentUser + ) +} + +function Get-VaultKey { + param([object]$Config) + + if ($Config.PSObject.Properties.Name -contains "vaultKeyProtected") { + return Unprotect-BytesForUser ([Convert]::FromBase64String($Config.vaultKeyProtected)) + } + + $key = New-RandomBytes 32 + $Config | Add-Member -NotePropertyName "vaultKeyProtected" -NotePropertyValue ([Convert]::ToBase64String((Protect-BytesForUser $key))) -Force + $Config | Add-Member -NotePropertyName "version" -NotePropertyValue 2 -Force + Save-ConfigObject $Config + return $key +} + +function Update-ConfigSchema { + param([object]$Config) + + $changed = $false + $resolvedProfile = Resolve-FirefoxProfilePathFromProfilesIni + $configuredProfile = $Config.profilePath + if (-not $configuredProfile -or ($configuredProfile -ne $resolvedProfile)) { + $configuredHasSessionData = $false + if ($configuredProfile -and (Test-Path -LiteralPath $configuredProfile)) { + $configuredHasSessionData = (Test-Path -LiteralPath (Join-Path $configuredProfile "cookies.sqlite")) -or + (Test-Path -LiteralPath (Join-Path $configuredProfile "sessionstore.jsonlz4")) -or + (Test-Path -LiteralPath (Join-Path $configuredProfile "sessionstore-backups\previous.jsonlz4")) + } + + if (-not $configuredHasSessionData -and (Test-Path -LiteralPath $resolvedProfile)) { + $Config | Add-Member -NotePropertyName "profilePath" -NotePropertyValue $resolvedProfile -Force + $changed = $true + } + } + + $currentPaths = @($Config.protectedRelativePaths) + $currentPathText = ($currentPaths | Sort-Object) -join "`n" + $expectedPathText = ($ProtectedRelativePaths | Sort-Object) -join "`n" + if (-not $currentPaths -or $currentPathText -ne $expectedPathText) { + $Config | Add-Member -NotePropertyName "protectedRelativePaths" -NotePropertyValue $ProtectedRelativePaths -Force + $changed = $true + } + + if ($Config.PSObject.Properties.Name -notcontains "version" -or [int]$Config.version -lt 2) { + $Config | Add-Member -NotePropertyName "version" -NotePropertyValue 2 -Force + $changed = $true + } + + if ($changed) { + Save-ConfigObject $Config + } + + return $Config +} + +function Resolve-FirefoxProfile { + if ($ProfilePath) { + $resolved = Resolve-Path -LiteralPath $ProfilePath -ErrorAction Stop + return $resolved.Path + } + + return Resolve-FirefoxProfilePathFromProfilesIni +} + +function Get-FirefoxPath { + $candidates = @( + "$env:ProgramFiles\Mozilla Firefox\firefox.exe", + "${env:ProgramFiles(x86)}\Mozilla Firefox\firefox.exe", + "$env:LocalAppData\Mozilla Firefox\firefox.exe" + ) + + foreach ($candidate in $candidates) { + if ($candidate -and (Test-Path -LiteralPath $candidate)) { + return $candidate + } + } + + $command = Get-Command firefox.exe -ErrorAction SilentlyContinue + if ($command) { + return $command.Source + } + + throw "Firefox wurde nicht gefunden." +} + +function Read-Pin { + param([string]$Prompt = "PIN") + + $secure = Read-Host $Prompt -AsSecureString + return Convert-SecureStringToPlainText $secure +} + +function Read-PinOrEscape { + param([string]$Prompt = "PIN") + + return Show-PinDialog -Prompt $Prompt +} + +function Show-PinDialog { + param([string]$Prompt = "PIN") + + [Windows.Forms.Application]::EnableVisualStyles() + + $backgroundColor = [Drawing.Color]::FromArgb(32, 32, 38) + $panelColor = [Drawing.Color]::FromArgb(43, 43, 50) + $textColor = [Drawing.Color]::FromArgb(238, 238, 242) + $mutedTextColor = [Drawing.Color]::FromArgb(190, 190, 198) + $accentColor = [Drawing.Color]::FromArgb(0, 120, 212) + $buttonColor = [Drawing.Color]::FromArgb(58, 58, 66) + + $form = New-Object Windows.Forms.Form + $form.Text = "Firefox" + $form.StartPosition = "CenterScreen" + $form.FormBorderStyle = "FixedDialog" + $form.MaximizeBox = $false + $form.MinimizeBox = $false + $form.TopMost = $true + $form.ClientSize = New-Object Drawing.Size(380, 164) + $form.Font = New-Object Drawing.Font("Segoe UI", 9) + $form.BackColor = $backgroundColor + $form.ForeColor = $textColor + + $label = New-Object Windows.Forms.Label + $label.Text = "$Prompt eingeben" + $label.AutoSize = $false + $label.Location = New-Object Drawing.Point(20, 18) + $label.Size = New-Object Drawing.Size(340, 22) + $label.ForeColor = $textColor + $label.BackColor = $backgroundColor + $form.Controls.Add($label) + + $hint = New-Object Windows.Forms.Label + $hint.Text = "ESC ${CharLowerOe}ffnet Firefox ohne private Sitzung." + $hint.AutoSize = $false + $hint.Location = New-Object Drawing.Point(20, 42) + $hint.Size = New-Object Drawing.Size(340, 22) + $hint.ForeColor = $mutedTextColor + $hint.BackColor = $backgroundColor + $form.Controls.Add($hint) + + $textBox = New-Object Windows.Forms.TextBox + $textBox.Location = New-Object Drawing.Point(20, 72) + $textBox.Size = New-Object Drawing.Size(340, 24) + $textBox.UseSystemPasswordChar = $true + $textBox.BackColor = $panelColor + $textBox.ForeColor = $textColor + $textBox.BorderStyle = "FixedSingle" + $form.Controls.Add($textBox) + + $okButton = New-Object Windows.Forms.Button + $okButton.Text = "Entsperren" + $okButton.Location = New-Object Drawing.Point(164, 114) + $okButton.Size = New-Object Drawing.Size(96, 30) + $okButton.DialogResult = [Windows.Forms.DialogResult]::OK + $okButton.BackColor = $accentColor + $okButton.ForeColor = [Drawing.Color]::White + $okButton.FlatStyle = "Flat" + $okButton.FlatAppearance.BorderSize = 0 + $form.Controls.Add($okButton) + + $decoyButton = New-Object Windows.Forms.Button + $decoyButton.Text = "Ohne Sitzung" + $decoyButton.Location = New-Object Drawing.Point(266, 114) + $decoyButton.Size = New-Object Drawing.Size(96, 30) + $decoyButton.DialogResult = [Windows.Forms.DialogResult]::Cancel + $decoyButton.BackColor = $buttonColor + $decoyButton.ForeColor = $textColor + $decoyButton.FlatStyle = "Flat" + $decoyButton.FlatAppearance.BorderColor = [Drawing.Color]::FromArgb(80, 80, 88) + $form.Controls.Add($decoyButton) + + $form.AcceptButton = $okButton + $form.CancelButton = $decoyButton + $form.Add_Shown({ $textBox.Focus() }) + + $result = $form.ShowDialog() + $pin = $textBox.Text + $form.Dispose() + + return [pscustomobject]@{ + Escaped = ($result -ne [Windows.Forms.DialogResult]::OK) + Pin = $pin + } +} + +function Get-RawVaultPath { + param([string]$RelativePath) + + return Join-Path $RawVaultDir $RelativePath +} + +function Move-PathToRawVault { + param( + [string]$SourcePath, + [string]$RelativePath + ) + + $target = Get-RawVaultPath $RelativePath + if (Test-Path -LiteralPath $target) { + Remove-Item -LiteralPath $target -Recurse -Force + } + + $targetParent = Split-Path -Parent $target + if (-not (Test-Path -LiteralPath $targetParent)) { + New-Item -ItemType Directory -Path $targetParent | Out-Null + } + + Move-Item -LiteralPath $SourcePath -Destination $target +} + +function Restore-PathFromRawVault { + param( + [string]$TargetPath, + [string]$RelativePath + ) + + $source = Get-RawVaultPath $RelativePath + if (-not (Test-Path -LiteralPath $source)) { + return $false + } + + if (Test-Path -LiteralPath $TargetPath) { + Remove-Item -LiteralPath $TargetPath -Recurse -Force + } + + if ($TargetPath.EndsWith(".sqlite")) { + Remove-SqliteTransientFilesBeforeRestore -Path $TargetPath + } + + $targetParent = Split-Path -Parent $TargetPath + if (-not (Test-Path -LiteralPath $targetParent)) { + New-Item -ItemType Directory -Path $targetParent | Out-Null + } + + Move-Item -LiteralPath $source -Destination $TargetPath + return $true +} + +function Get-EncryptedVaultPath { + param([string]$RelativePath) + + return Join-Path $VaultDir ($RelativePath + ".ffsv") +} + +function Protect-PathToEncryptedVault { + param( + [string]$SourcePath, + [string]$RelativePath, + [byte[]]$Key + ) + + $item = Get-Item -LiteralPath $SourcePath + if (-not $item.PSIsContainer) { + Protect-BytesToVault -Plain ([IO.File]::ReadAllBytes($SourcePath)) -VaultPath (Get-EncryptedVaultPath $RelativePath) -Key $Key -PayloadType 1 + return + } + + $root = $item.FullName.TrimEnd('\') + $files = Get-ChildItem -LiteralPath $SourcePath -Recurse -File -Force + foreach ($file in $files) { + $childRelative = $file.FullName.Substring($root.Length).TrimStart('\') + $vaultRelative = Join-Path $RelativePath $childRelative + Protect-BytesToVault -Plain ([IO.File]::ReadAllBytes($file.FullName)) -VaultPath (Get-EncryptedVaultPath $vaultRelative) -Key $Key -PayloadType 1 + } +} + +function Restore-EncryptedVaultItem { + param( + [string]$TargetPath, + [string]$RelativePath, + [byte[]]$Key, + [string]$Pin, + [int]$Iterations + ) + + $vaultFile = Get-EncryptedVaultPath $RelativePath + if (Test-Path -LiteralPath $vaultFile) { + Unprotect-VaultToPath -VaultPath $vaultFile -TargetPath $TargetPath -Key $Key -Pin $Pin -Iterations $Iterations + Remove-Item -LiteralPath $vaultFile -Force + return $true + } + + $vaultDirectory = Join-Path $VaultDir $RelativePath + if (-not (Test-Path -LiteralPath $vaultDirectory)) { + return $false + } + + if (Test-Path -LiteralPath $TargetPath) { + Remove-Item -LiteralPath $TargetPath -Recurse -Force + } + New-Item -ItemType Directory -Path $TargetPath | Out-Null + + $files = Get-ChildItem -LiteralPath $vaultDirectory -Recurse -File -Filter "*.ffsv" -Force + foreach ($file in $files) { + $childRelative = $file.FullName.Substring($vaultDirectory.Length).TrimStart('\') + $childRelative = $childRelative.Substring(0, $childRelative.Length - 5) + $targetChild = Join-Path $TargetPath $childRelative + Unprotect-VaultToPath -VaultPath $file.FullName -TargetPath $targetChild -Key $Key -Pin $Pin -Iterations $Iterations + Remove-Item -LiteralPath $file.FullName -Force + } + + $remaining = Get-ChildItem -LiteralPath $vaultDirectory -Recurse -Force -ErrorAction SilentlyContinue + if (-not $remaining) { + Remove-Item -LiteralPath $vaultDirectory -Recurse -Force + } + + return $true +} + +function Get-LockRelativePaths { + param([object]$Config) + + $paths = New-Object System.Collections.Generic.List[string] + foreach ($path in @($Config.protectedRelativePaths)) { + if ($path -and $path -ne "storage") { + $paths.Add($path) + } + } + + $storageDefault = Join-Path $Config.profilePath "storage\default" + if (Test-Path -LiteralPath $storageDefault) { + $siteStorage = Get-ChildItem -LiteralPath $storageDefault -Directory -Force | + Where-Object { $_.Name -like "http*" -or $_.Name -like "file*" } + + foreach ($site in $siteStorage) { + $paths.Add(("storage\default\" + $site.Name)) + } + } + + return $paths | Select-Object -Unique +} + +function Restore-AllEncryptedVaultItems { + param( + [object]$Config, + [byte[]]$Key, + [string]$Pin + ) + + if (-not (Test-Path -LiteralPath $VaultDir)) { + return + } + + $vaultFiles = Get-ChildItem -LiteralPath $VaultDir -Recurse -File -Filter "*.ffsv" -Force | + Sort-Object FullName + + foreach ($vaultFile in $vaultFiles) { + $relative = $vaultFile.FullName.Substring($VaultDir.Length).TrimStart('\') + $relative = $relative.Substring(0, $relative.Length - 5) + $target = Join-Path $Config.profilePath $relative + Unprotect-VaultToPath -VaultPath $vaultFile.FullName -TargetPath $target -Key $Key -Pin $Pin -Iterations ([int]$Config.iterations) + Remove-Item -LiteralPath $vaultFile.FullName -Force + } +} + +function Get-DecoyProfilePath { + Ensure-StateDir + if (-not (Test-Path -LiteralPath $DecoyProfileDir)) { + New-Item -ItemType Directory -Path $DecoyProfileDir | Out-Null + } + + $prefsPath = Join-Path $DecoyProfileDir "prefs.js" + if (-not (Test-Path -LiteralPath $prefsPath)) { + @( + 'user_pref("browser.startup.page", 0);', + 'user_pref("browser.shell.checkDefaultBrowser", false);' + ) | Set-Content -LiteralPath $prefsPath -Encoding UTF8 + } + + return $DecoyProfileDir +} + +function Start-DecoyFirefox { + $decoyProfile = Get-DecoyProfilePath + Write-Host "${CharUpperOe}ffne Firefox ohne private Sitzung..." + Start-Process -FilePath (Get-FirefoxPath) -ArgumentList @("-no-remote", "-profile", "`"$decoyProfile`"") +} + +function Start-RealFirefox { + param([string]$Profile) + + Write-Host "${CharUpperOe}ffne dein Firefox-Profil..." + Start-Process -FilePath (Get-FirefoxPath) -ArgumentList @("-no-remote", "-profile", "`"$Profile`"") | Out-Null + Start-WatchOnClose -Profile $Profile +} + +function Start-WatchOnClose { + param([string]$Profile) + + $powershell = (Get-Process -Id $PID).Path + $arguments = @( + "-NoProfile", + "-ExecutionPolicy", "Bypass", + "-WindowStyle", "Hidden", + "-File", "`"$PSCommandPath`"", + "-Mode", "WatchClose", + "-ProfilePath", "`"$Profile`"" + ) + Start-Process -FilePath $powershell -ArgumentList $arguments -WindowStyle Hidden | Out-Null +} + +function Invoke-WatchClose { + Start-Sleep -Seconds 5 + + $deadline = (Get-Date).AddSeconds(30) + while ((Get-Date) -lt $deadline) { + if (Get-Process firefox -ErrorAction SilentlyContinue) { + break + } + Start-Sleep -Seconds 1 + } + + while (Get-Process firefox -ErrorAction SilentlyContinue) { + Start-Sleep -Seconds 2 + } + + Invoke-Lock +} + +function Test-Pin { + param( + [object]$Config, + [string]$Pin + ) + + $salt = [Convert]::FromBase64String($Config.pinSalt) + $expected = [Convert]::FromBase64String($Config.pinHash) + $actual = Get-PinHash -Pin $Pin -Salt $salt -Iterations ([int]$Config.iterations) + + if ($actual.Length -ne $expected.Length) { + return $false + } + + $diff = 0 + for ($i = 0; $i -lt $actual.Length; $i++) { + $diff = $diff -bor ($actual[$i] -bxor $expected[$i]) + } + + return $diff -eq 0 +} + +function Get-AesFromPin { + param( + [string]$Pin, + [byte[]]$Salt, + [byte[]]$Iv, + [int]$Iterations + ) + + $aes = [Security.Cryptography.Aes]::Create() + $aes.Mode = [Security.Cryptography.CipherMode]::CBC + $aes.Padding = [Security.Cryptography.PaddingMode]::PKCS7 + $aes.Key = Get-DerivedBytes -Pin $Pin -Salt $Salt -Iterations $Iterations -Length 32 + $aes.IV = $Iv + return $aes +} + +function Get-AesFromKey { + param( + [byte[]]$Key, + [byte[]]$Iv + ) + + $aes = [Security.Cryptography.Aes]::Create() + $aes.Mode = [Security.Cryptography.CipherMode]::CBC + $aes.Padding = [Security.Cryptography.PaddingMode]::PKCS7 + $aes.Key = $Key + $aes.IV = $Iv + return $aes +} + +function Remove-SqliteShm { + param([string]$Path) + + if (-not ($Path.EndsWith(".sqlite"))) { + return + } + + $sidecar = $Path + "-shm" + if (Test-Path -LiteralPath $sidecar) { + Remove-Item -LiteralPath $sidecar -Force + } +} + +function Remove-SqliteTransientFilesBeforeRestore { + param([string]$Path) + + if (-not ($Path.EndsWith(".sqlite"))) { + return + } + + foreach ($suffix in @("-shm", "-wal")) { + $sidecar = $Path + $suffix + if (Test-Path -LiteralPath $sidecar) { + Remove-Item -LiteralPath $sidecar -Force + } + } +} + +function Protect-BytesToVault { + param( + [byte[]]$Plain, + [string]$VaultPath, + [byte[]]$Key, + [byte]$PayloadType + ) + + $iv = New-RandomBytes 16 + $aes = Get-AesFromKey -Key $Key -Iv $iv + + try { + $encryptor = $aes.CreateEncryptor() + $cipher = $encryptor.TransformFinalBlock($Plain, 0, $Plain.Length) + $payload = Join-ByteArrays -Arrays @([byte[]](70, 70, 83, 86, 3, $PayloadType), $iv, $cipher) + + $vaultParent = Split-Path -Parent $VaultPath + if (-not (Test-Path -LiteralPath $vaultParent)) { + New-Item -ItemType Directory -Path $vaultParent | Out-Null + } + + [IO.File]::WriteAllBytes($VaultPath, $payload) + } + finally { + $aes.Dispose() + } +} + +function Protect-Path { + param( + [string]$SourcePath, + [string]$VaultPath, + [byte[]]$Key + ) + + $item = Get-Item -LiteralPath $SourcePath + if ($item.PSIsContainer) { + $tempZip = Join-Path $StateDir ("vault-" + [guid]::NewGuid().ToString("N") + ".zip") + try { + [IO.Compression.ZipFile]::CreateFromDirectory($SourcePath, $tempZip, [IO.Compression.CompressionLevel]::Optimal, $false) + Protect-BytesToVault -Plain ([IO.File]::ReadAllBytes($tempZip)) -VaultPath $VaultPath -Key $Key -PayloadType 2 + } + finally { + if (Test-Path -LiteralPath $tempZip) { + Remove-Item -LiteralPath $tempZip -Force + } + } + return + } + + Protect-BytesToVault -Plain ([IO.File]::ReadAllBytes($SourcePath)) -VaultPath $VaultPath -Key $Key -PayloadType 1 +} + +function Unprotect-VaultToPath { + param( + [string]$VaultPath, + [string]$TargetPath, + [byte[]]$Key, + [string]$Pin = "", + [int]$Iterations = 0 + ) + + $payload = [IO.File]::ReadAllBytes($VaultPath) + if ($payload.Length -lt 21 -or $payload[0] -ne 70 -or $payload[1] -ne 70 -or $payload[2] -ne 83 -or $payload[3] -ne 86) { + throw "Invalid vault file: $VaultPath" + } + + $version = $payload[4] + $payloadType = 1 + if ($version -eq 1) { + if (-not $Pin) { + throw "Alte Vault-Datei benötigt PIN-basiertes Entsperren: $VaultPath" + } + $salt = $payload[5..20] + $iv = $payload[21..36] + $cipher = $payload[37..($payload.Length - 1)] + $aes = Get-AesFromPin -Pin $Pin -Salt $salt -Iv $iv -Iterations $Iterations + } + elseif ($version -eq 2) { + $iv = $payload[5..20] + $cipher = $payload[21..($payload.Length - 1)] + $aes = Get-AesFromKey -Key $Key -Iv $iv + } + elseif ($version -eq 3) { + $payloadType = $payload[5] + $iv = $payload[6..21] + $cipher = $payload[22..($payload.Length - 1)] + $aes = Get-AesFromKey -Key $Key -Iv $iv + } + else { + throw "Unsupported vault file version: $version" + } + + try { + $decryptor = $aes.CreateDecryptor() + $plain = $decryptor.TransformFinalBlock($cipher, 0, $cipher.Length) + + if (Test-Path -LiteralPath $TargetPath) { + Remove-Item -LiteralPath $TargetPath -Recurse -Force + } + + if ($payloadType -eq 2) { + $tempZip = Join-Path $StateDir ("restore-" + [guid]::NewGuid().ToString("N") + ".zip") + try { + [IO.File]::WriteAllBytes($tempZip, $plain) + New-Item -ItemType Directory -Path $TargetPath | Out-Null + [IO.Compression.ZipFile]::ExtractToDirectory($tempZip, $TargetPath) + } + finally { + if (Test-Path -LiteralPath $tempZip) { + Remove-Item -LiteralPath $tempZip -Force + } + } + } + else { + $targetParent = Split-Path -Parent $TargetPath + if (-not (Test-Path -LiteralPath $targetParent)) { + New-Item -ItemType Directory -Path $targetParent | Out-Null + } + + Remove-SqliteTransientFilesBeforeRestore -Path $TargetPath + [IO.File]::WriteAllBytes($TargetPath, $plain) + } + } + finally { + $aes.Dispose() + } +} + +function Stop-Firefox { + $processes = Get-Process firefox -ErrorAction SilentlyContinue + if ($processes) { + Write-Host "Schlie${CharEszett}e Firefox..." + foreach ($process in $processes) { + try { + $null = $process.CloseMainWindow() + } + catch { + } + } + + $deadline = (Get-Date).AddSeconds(45) + while ((Get-Date) -lt $deadline) { + Start-Sleep -Milliseconds 500 + if (-not (Get-Process firefox -ErrorAction SilentlyContinue)) { + return $true + } + } + + Write-Host "Firefox wurde nicht sauber geschlossen. Sperren abgebrochen, um Sitzungsdaten nicht zu besch${CharLowerAe}digen." + Write-Host "Schlie${CharEszett}e Firefox manuell und starte den Launcher erneut." + return $false + } + + return $true +} + +function Set-ResumeSessionOnce { + param([string]$Profile) + + $prefsPath = Join-Path $Profile "prefs.js" + $line = 'user_pref("browser.sessionstore.resume_session_once", true);' + + if (-not (Test-Path -LiteralPath $prefsPath)) { + Set-Content -LiteralPath $prefsPath -Value $line -Encoding UTF8 + return + } + + $prefs = Get-Content -LiteralPath $prefsPath + $pattern = '^\s*user_pref\("browser\.sessionstore\.resume_session_once",' + $found = $false + $updated = foreach ($pref in $prefs) { + if ($pref -match $pattern) { + $found = $true + $line + } + else { + $pref + } + } + + if (-not $found) { + $updated += $line + } + + Set-Content -LiteralPath $prefsPath -Value $updated -Encoding UTF8 +} + +function Invoke-Setup { + Ensure-StateDir + + $profile = Resolve-FirefoxProfile + Write-Host "Firefox-Profil: $profile" + + while ($true) { + $pin1 = Read-Pin "New vault PIN" + $pin2 = Read-Pin "Repeat vault PIN" + + try { + if ($pin1.Length -lt 4) { + Write-Host "PIN muss mindestens 4 Zeichen haben." + continue + } + if ($pin1 -ne $pin2) { + Write-Host "PINs stimmen nicht ${CharLowerUe}berein." + continue + } + + $iterations = 210000 + $pinSalt = New-RandomBytes 16 + $pinHash = Get-PinHash -Pin $pin1 -Salt $pinSalt -Iterations $iterations + + Save-Config ([pscustomobject]@{ + version = 2 + profilePath = $profile + iterations = $iterations + pinSalt = [Convert]::ToBase64String($pinSalt) + pinHash = [Convert]::ToBase64String($pinHash) + vaultKeyProtected = [Convert]::ToBase64String((Protect-BytesForUser (New-RandomBytes 32))) + protectedRelativePaths = $ProtectedRelativePaths + createdAt = (Get-Date).ToString("o") + }) + + Write-Host "Einrichtung abgeschlossen." + return + } + finally { + $pin1 = $null + $pin2 = $null + } + } +} + +function Get-ConfigOrSetup { + $config = Read-Config + if (-not $config) { + Invoke-Setup + $config = Read-Config + } + return Update-ConfigSchema $config +} + +function Invoke-Lock { + Ensure-StateDir + $config = Get-ConfigOrSetup + $key = Get-VaultKey $config + + try { + if (-not (Stop-Firefox)) { + exit 1 + } + + $lockedAny = $false + foreach ($relativePath in (Get-LockRelativePaths $config)) { + $source = Join-Path $config.profilePath $relativePath + if (-not (Test-Path -LiteralPath $source)) { + continue + } + + Protect-PathToEncryptedVault -SourcePath $source -RelativePath $relativePath -Key $key + Remove-Item -LiteralPath $source -Recurse -Force + Remove-SqliteShm -Path $source + $lockedAny = $true + } + + if ($lockedAny) { + Write-Host "Firefox-Sitzung ist verschl${CharLowerUe}sselt gesperrt." + } + else { + Write-Host "Nichts zu sperren. Firefox ist eventuell bereits gesperrt." + } + } + finally { + $key = $null + } +} + +function Invoke-Unlock { + Ensure-StateDir + $config = Get-ConfigOrSetup + $pinResult = Read-PinOrEscape "PIN" + if ($pinResult.Escaped) { + Start-DecoyFirefox + return + } + + $pin = $pinResult.Pin + + try { + if (-not (Test-Pin -Config $config -Pin $pin)) { + Write-Host "Falscher PIN. Firefox bleibt gesperrt." + Start-Sleep -Seconds 2 + exit 1 + } + + if (Test-FirefoxRunning) { + if (-not (Stop-Firefox)) { + exit 1 + } + } + + $key = Get-VaultKey $config + foreach ($relativePath in (Get-LockRelativePaths $config)) { + $target = Join-Path $config.profilePath $relativePath + if (Restore-PathFromRawVault -TargetPath $target -RelativePath $relativePath) { + continue + } + } + Restore-AllEncryptedVaultItems -Config $config -Key $key -Pin $pin + + Set-ResumeSessionOnce -Profile $config.profilePath + Start-RealFirefox -Profile $config.profilePath + } + finally { + $pin = $null + $key = $null + } +} + +function Test-VaultHasFiles { + if (Test-Path -LiteralPath $RawVaultDir) { + $rawVaulted = Get-ChildItem -LiteralPath $RawVaultDir -Recurse -Force -ErrorAction SilentlyContinue | + Where-Object { -not $_.PSIsContainer } | + Select-Object -First 1 + if ($rawVaulted) { + return $true + } + } + + if (Test-Path -LiteralPath $VaultDir) { + $vaulted = Get-ChildItem -LiteralPath $VaultDir -Recurse -File -Filter "*.ffsv" -ErrorAction SilentlyContinue + return [bool]$vaulted + } + + return $false +} + +function Test-FirefoxRunning { + return [bool](Get-Process firefox -ErrorAction SilentlyContinue) +} + +function Invoke-Toggle { + if ((Test-FirefoxRunning) -and -not (Test-VaultHasFiles)) { + Write-Host "Firefox ist aktiv. Sperre Sitzung..." + Invoke-Lock + return + } + + if (Test-VaultHasFiles) { + Write-Host "Firefox ist gesperrt. Entsperre..." + } + else { + Write-Host "Firefox l${CharLowerAe}uft nicht. Starte ${CharLowerUe}ber Vault-Entsperrung..." + } + + Invoke-Unlock +} + +function New-FirefoxShortcut { + Ensure-StateDir + + $firefox = Get-FirefoxPath + $shortcutPath = Join-Path $BaseDir "Firefox.lnk" + $launcherPath = Join-Path $BaseDir "FirefoxLauncher.exe" + + $shell = New-Object -ComObject WScript.Shell + $shortcut = $shell.CreateShortcut($shortcutPath) + $shortcut.TargetPath = $launcherPath + $shortcut.Arguments = "" + $shortcut.WorkingDirectory = $BaseDir + $shortcut.IconLocation = "$firefox,0" + $shortcut.Description = "Firefox Session Vault" + $shortcut.WindowStyle = 1 + $shortcut.Save() + + Write-Host "Verkn${CharLowerUe}pfung erstellt: $shortcutPath" +} + +function Invoke-Status { + $config = Read-Config + if (-not $config) { + Write-Host "Nicht eingerichtet." + return + } + $config = Update-ConfigSchema $config + + $vaulted = @() + if (Test-Path -LiteralPath $VaultDir) { + $vaulted += Get-ChildItem -LiteralPath $VaultDir -Recurse -File -ErrorAction SilentlyContinue + } + if (Test-Path -LiteralPath $RawVaultDir) { + $vaulted += Get-ChildItem -LiteralPath $RawVaultDir -Recurse -Force -ErrorAction SilentlyContinue | + Where-Object { -not $_.PSIsContainer } + } + + if ($vaulted) { + Write-Host "Gesperrte Elemente im Vault: $($vaulted.Count)" + } + else { + Write-Host "Keine gesperrten Dateien im Vault. Firefox-Sitzungen sind wahrscheinlich entsperrt." + } + Write-Host "Firefox-Profil: $($config.profilePath)" +} + +switch ($Mode) { + "Toggle" { Invoke-Toggle } + "Setup" { Invoke-Setup } + "Lock" { Invoke-Lock } + "Unlock" { Invoke-Unlock } + "Shortcut" { New-FirefoxShortcut } + "Status" { Invoke-Status } + "WatchClose" { Invoke-WatchClose } + "Reset" { + Ensure-StateDir + Write-Host "Reset entfernt nur die Vault-Konfiguration, nicht die Firefox-Daten." + if (Test-Path -LiteralPath $ConfigPath) { + Remove-Item -LiteralPath $ConfigPath -Force + } + Write-Host "Reset abgeschlossen." + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..34c300a --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# Firefox Secure + +Windows helper for protecting a Firefox session with a PIN. + +It stores Firefox session data outside the active profile while locked, then restores it after the correct PIN is entered. Pressing `Esc` in the PIN dialog opens a neutral Firefox profile instead. + +## Build + +Run: + +```powershell +.\Build.ps1 +``` + +This compiles `FirefoxLauncher.exe` as a windowless launcher and recreates `Firefox.lnk` with the normal Firefox icon. + +## Use + +1. Run `Firefox.lnk`. +2. On first setup, choose a PIN. +3. Use `Firefox.lnk` instead of the normal Firefox shortcut. + +Behavior: + +- If Firefox is locked, a small dark PIN dialog is shown. +- Correct PIN restores the protected session and opens your real Firefox profile. +- `Esc` opens a separate neutral Firefox profile. +- Wrong PIN keeps the protected profile locked. +- If your real Firefox profile is open and you close it with `X`, a background watcher locks the session afterwards. + +## Important Firefox Setting + +Disable this Firefox setting: + +`Clear cookies and site data every time you close Firefox` + +If Firefox deletes website data on exit, this tool can only protect what is still present after Firefox closes. + +## What Is Protected + +The vault protects: + +- Firefox cookies +- `webappsstore.sqlite` +- selected website storage under `storage\default\https...` +- session restore files + +New locks store encrypted `.ffsv` files in `.firefox-session-vault\vault`. + +## Security Notes + +This is a practical privacy lock for a shared Windows session, not enterprise-grade isolation. + +It does not protect against someone who can modify this folder, kill processes, inspect the same Windows account deeply, or use admin rights. Separate Windows accounts are still the real security boundary. + +Do not commit `.firefox-session-vault`; it contains local encrypted session data.