1173 lines
34 KiB
PowerShell
1173 lines
34 KiB
PowerShell
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."
|
|
}
|
|
}
|