Private/Integrity.ps1
|
# Tamper detection (varaktighet) for verifications and their attachments. # # Each fiscal year directory may hold an append-only integrity.txt: a hash chain # with one record per sealed verification, sealed attachment, removed attachment # and sealed version of the year metadata (year.txt, ib.txt). A record stores the SHA-256 of the item and a chain hash over the # previous record's chain hash and the record's own fields, so changing, removing # or re-ordering a record breaks every chain hash after it. See # docs/File-format.md for the format. $script:LedgerIntegrityFileName = 'integrity.txt' $script:LedgerIntegrityGenesis = '0' * 64 $script:LedgerIntegrityKinds = @('Verification', 'Attachment', 'AttachmentRemoved', 'Metadata') $script:LedgerIntegrityMetadataFiles = @('year.txt', 'ib.txt') function Get-LedgerSha256Hex { [CmdletBinding()] [OutputType([string])] param ( [Parameter(Mandatory)] [AllowEmptyCollection()] [byte[]]$Bytes ) return [Convert]::ToHexString([System.Security.Cryptography.SHA256]::HashData($Bytes)).ToLowerInvariant() } function Get-LedgerContentHash { <# .SYNOPSIS SHA-256 of a file, with CRLF normalised to LF for text files. .DESCRIPTION Git (core.autocrlf / text=auto) converts line endings of files it considers text: those without a NUL byte in the first 8000 bytes. The same rule is used here so a journal checked out on Windows and on Linux gives the same hashes. #> [CmdletBinding()] [OutputType([string])] param ( [Parameter(Mandatory)] [string]$Path ) $bytes = [System.IO.File]::ReadAllBytes($Path) $probe = [Math]::Min($bytes.Length, 8000) $isText = $probe -eq 0 -or [Array]::IndexOf($bytes, [byte]0, 0, $probe) -lt 0 if ($isText -and [Array]::IndexOf($bytes, [byte]13) -ge 0) { # Latin-1 maps every byte to one char, so the round trip preserves all # other bytes exactly. $latin1 = [System.Text.Encoding]::Latin1 $bytes = $latin1.GetBytes($latin1.GetString($bytes).Replace("`r`n", "`n")) } return Get-LedgerSha256Hex -Bytes $bytes } function Get-LedgerIntegrityChainHash { [CmdletBinding()] [OutputType([string])] param ( [Parameter(Mandatory)] [string]$Previous, [Parameter(Mandatory)] [string]$Kind, [Parameter(Mandatory)] [string]$Key, [Parameter(Mandatory)] [string]$Sealed, [Parameter(Mandatory)] [string]$Hash ) $text = "$Previous`t$Kind`t$Key`t$Sealed`t$Hash" return Get-LedgerSha256Hex -Bytes ([System.Text.Encoding]::UTF8.GetBytes($text)) } function Get-LedgerIntegrityPath { [CmdletBinding()] [OutputType([string])] param ([Parameter(Mandatory)] [string]$YearDir) return Join-Path $YearDir $script:LedgerIntegrityFileName } function Read-LedgerIntegrityChain { <# .SYNOPSIS Reads integrity.txt. Returns $null when the year has no chain. #> [CmdletBinding()] param ([Parameter(Mandatory)] [string]$YearDir) $path = Get-LedgerIntegrityPath -YearDir $YearDir if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { return $null } $records = [System.Collections.Generic.List[object]]::new() $lineNumber = 0 foreach ($line in [System.IO.File]::ReadAllLines($path, [System.Text.UTF8Encoding]::new($false))) { $lineNumber++ if ([string]::IsNullOrWhiteSpace($line) -or $line.StartsWith(';')) { continue } $parts = $line -split "`t" $wellFormed = $parts.Count -eq 5 -and $script:LedgerIntegrityKinds -ccontains $parts[0] -and $parts[1] -ne '' -and $parts[3] -cmatch '^[0-9a-f]{64}$' -and $parts[4] -cmatch '^[0-9a-f]{64}$' $records.Add([pscustomobject]@{ LineNumber = $lineNumber WellFormed = $wellFormed Kind = if ($wellFormed) { $parts[0] } else { $null } Key = if ($wellFormed) { $parts[1] } else { $null } Sealed = if ($wellFormed) { $parts[2] } else { $null } Hash = if ($wellFormed) { $parts[3] } else { $null } ChainHash = if ($wellFormed) { $parts[4] } else { $null } Line = $line }) } return , $records } function Get-LedgerIntegrityHead { [CmdletBinding()] [OutputType([string])] param ([Parameter()] [AllowNull()] [object]$Chain) $head = $script:LedgerIntegrityGenesis foreach ($r in @($Chain)) { if ($r -and $r.WellFormed) { $head = $r.ChainHash } } return $head } function Get-LedgerIntegrityState { <# .SYNOPSIS Folds a chain into the sealed verifications, the live sealed attachments and the latest sealed version of each metadata file. #> [CmdletBinding()] param ([Parameter()] [AllowNull()] [object]$Chain) $verifications = [ordered]@{} $attachments = [ordered]@{} $metadata = [ordered]@{} foreach ($r in @($Chain)) { if (-not $r -or -not $r.WellFormed) { continue } switch ($r.Kind) { 'Verification' { if (-not $verifications.Contains($r.Key)) { $verifications[$r.Key] = $r.Hash } } 'Attachment' { $attachments[$r.Key] = $r.Hash } 'AttachmentRemoved' { $attachments.Remove($r.Key) } 'Metadata' { $metadata[$r.Key] = $r.Hash } } } return [pscustomobject]@{ Verifications = $verifications; Attachments = $attachments; Metadata = $metadata } } function Test-LedgerYearClosedStatus { [CmdletBinding()] [OutputType([bool])] param ([Parameter(Mandatory)] [string]$YearDir) $yearFile = Join-Path $YearDir 'year.txt' if (-not (Test-Path -LiteralPath $yearFile -PathType Leaf)) { return $false } return [bool](@(Get-Content -LiteralPath $yearFile) -match '^Status:\s*Closed') } function Get-LedgerYearMetadataRecord { <# .SYNOPSIS The Metadata records needed to seal year.txt and ib.txt. .DESCRIPTION Seals metadata files the chain does not cover yet, and a new version of the files in -Changed (written legitimately by the caller) whose hash differs from the sealed one. A sealed file changed by anything else is left for Test-LedgerIntegrity to report. A closed year whose existing chain predates metadata sealing is left untouched, so a final chain hash stored when it was closed stays valid; -Closing (used by Close-LedgerFiscalYear) seals it anyway. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [string]$YearDir, [Parameter()] [AllowNull()] [object]$Chain, [string[]]$Changed = @(), [switch]$Closing ) $state = Get-LedgerIntegrityState -Chain $Chain if ($null -ne $Chain -and $state.Metadata.Count -eq 0 -and -not $Closing -and (Test-LedgerYearClosedStatus -YearDir $YearDir)) { return @() } $records = foreach ($name in $script:LedgerIntegrityMetadataFiles) { $path = Join-Path $YearDir $name if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { continue } if ($state.Metadata.Contains($name) -and $name -notin $Changed) { continue } $hash = Get-LedgerContentHash -Path $path if ($state.Metadata[$name] -ne $hash) { [pscustomobject]@{ Kind = 'Metadata'; Key = $name; Hash = $hash } } } return @($records) } function Get-LedgerYearContent { <# .SYNOPSIS Lists the verification files and attachments of a fiscal year directory. #> [CmdletBinding()] param ([Parameter(Mandatory)] [string]$YearDir) $verifications = @(Get-ChildItem -LiteralPath $YearDir -File -Filter 'ver*.txt' -ErrorAction SilentlyContinue | Where-Object { $_.Name -match '^ver(\d+)\.txt$' } | ForEach-Object { [pscustomobject]@{ Number = [int]$Matches[1]; Key = [string][int]$Matches[1]; Path = $_.FullName } } | Sort-Object Number) $attachments = @(Get-ChildItem -LiteralPath $YearDir -Directory -Filter 'ver*' -ErrorAction SilentlyContinue | Where-Object { $_.Name -match '^ver(\d+)$' } | ForEach-Object { $number = [int]$Matches[1] Get-ChildItem -LiteralPath $_.FullName -File | Sort-Object Name | ForEach-Object { [pscustomobject]@{ Number = $number; Key = "$number/$($_.Name)"; Path = $_.FullName } } } | Sort-Object Number, Key) return [pscustomobject]@{ Verifications = $verifications; Attachments = $attachments } } function Write-LedgerIntegrityRecord { <# .SYNOPSIS Appends records (Kind, Key, Hash) to the chain, creating the file if needed. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [string]$YearDir, [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Record, # The chain as already read under the same lock, to avoid reading it again. [Parameter()] [AllowNull()] [object]$Chain ) if ($Record.Count -eq 0) { return } $journalLock = $null try { $journalLock = Enter-LedgerJournalLock -JournalPath (Split-Path -Path $YearDir -Parent) $path = Get-LedgerIntegrityPath -YearDir $YearDir if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { Set-LedgerFileContent -Path $path -Value @( '; PSLedger integrity chain' "; Kind`tKey`tSealed`tHash`tChainHash" ) $Chain = $null } elseif (-not $PSBoundParameters.ContainsKey('Chain')) { $Chain = Read-LedgerIntegrityChain -YearDir $YearDir } $previous = Get-LedgerIntegrityHead -Chain $Chain $sealed = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ', [System.Globalization.CultureInfo]::InvariantCulture) $nl = [Environment]::NewLine $sb = [System.Text.StringBuilder]::new() # The chain is append-only, so it is appended rather than rewritten. If the # last line lacks its line break (an interrupted append) start a new line. $bytes = [System.IO.File]::ReadAllBytes($path) if ($bytes.Length -gt 0 -and $bytes[-1] -ne 10) { [void]$sb.Append($nl) } foreach ($r in $Record) { $chainHash = Get-LedgerIntegrityChainHash -Previous $previous -Kind $r.Kind -Key $r.Key -Sealed $sealed -Hash $r.Hash [void]$sb.Append("$($r.Kind)`t$($r.Key)`t$sealed`t$($r.Hash)`t$chainHash$nl") $previous = $chainHash } [System.IO.File]::AppendAllText($path, $sb.ToString(), [System.Text.UTF8Encoding]::new($false)) } finally { Exit-LedgerJournalLock -Lock $journalLock } } function Protect-LedgerYearContent { <# .SYNOPSIS Seals every verification, attachment and metadata file in a year that the chain lacks. .DESCRIPTION -ChangedMetadata names metadata files the caller has just written, whose new version is sealed too. -Closing seals metadata of a closed year whose chain predates metadata sealing (see Get-LedgerYearMetadataRecord). #> [CmdletBinding()] param ( [Parameter(Mandatory)] [string]$YearDir, [string[]]$ChangedMetadata = @(), [switch]$Closing ) $journalLock = $null try { $journalLock = Enter-LedgerJournalLock -JournalPath (Split-Path -Path $YearDir -Parent) $chain = Read-LedgerIntegrityChain -YearDir $YearDir $state = Get-LedgerIntegrityState -Chain $chain $content = Get-LedgerYearContent -YearDir $YearDir $records = [System.Collections.Generic.List[object]]::new() foreach ($m in @(Get-LedgerYearMetadataRecord -YearDir $YearDir -Chain $chain -Changed $ChangedMetadata -Closing:$Closing)) { $records.Add($m) } foreach ($v in $content.Verifications) { if (-not $state.Verifications.Contains($v.Key)) { $records.Add([pscustomobject]@{ Kind = 'Verification'; Key = $v.Key; Hash = (Get-LedgerContentHash -Path $v.Path) }) } foreach ($a in @($content.Attachments | Where-Object Number -EQ $v.Number)) { if (-not $state.Attachments.Contains($a.Key)) { $records.Add([pscustomobject]@{ Kind = 'Attachment'; Key = $a.Key; Hash = (Get-LedgerContentHash -Path $a.Path) }) } } } Write-LedgerIntegrityRecord -YearDir $YearDir -Record $records.ToArray() -Chain $chain return $records.Count } finally { Exit-LedgerJournalLock -Lock $journalLock } } function Register-LedgerIntegrityItem { <# .SYNOPSIS Seals a newly written verification or attachment. .DESCRIPTION When the year has no chain yet (a journal created before tamper detection), everything already in the year is sealed first, which includes the new item. An open year whose chain does not cover its metadata yet has the metadata sealed before the item. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [string]$YearDir, [Parameter(Mandatory)] [ValidateSet('Verification', 'Attachment')] [string]$Kind, [Parameter(Mandatory)] [string]$Key, [Parameter(Mandatory)] [string]$Path ) $journalLock = $null try { $journalLock = Enter-LedgerJournalLock -JournalPath (Split-Path -Path $YearDir -Parent) if (-not (Test-Path -LiteralPath (Get-LedgerIntegrityPath -YearDir $YearDir) -PathType Leaf)) { [void](Protect-LedgerYearContent -YearDir $YearDir) return } $chain = Read-LedgerIntegrityChain -YearDir $YearDir $records = [System.Collections.Generic.List[object]]::new() if (-not @($chain | Where-Object { $_.WellFormed -and $_.Kind -eq 'Metadata' })) { foreach ($m in @(Get-LedgerYearMetadataRecord -YearDir $YearDir -Chain $chain)) { $records.Add($m) } } $records.Add([pscustomobject]@{ Kind = $Kind; Key = $Key; Hash = (Get-LedgerContentHash -Path $Path) }) Write-LedgerIntegrityRecord -YearDir $YearDir -Record $records.ToArray() -Chain $chain } finally { Exit-LedgerJournalLock -Lock $journalLock } } function Register-LedgerYearMetadata { <# .SYNOPSIS Seals a metadata file (year.txt or ib.txt) the caller has just written. .DESCRIPTION Creates the chain when the year has none, sealing everything already in the year. Otherwise only metadata is sealed; unsealed verifications stay reported. Repeating it after an interruption appends nothing once the current version is sealed. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [string]$YearDir, [Parameter(Mandatory)] [ValidateSet('year.txt', 'ib.txt')] [string]$Name, [switch]$Closing ) $journalLock = $null try { $journalLock = Enter-LedgerJournalLock -JournalPath (Split-Path -Path $YearDir -Parent) if (-not (Test-Path -LiteralPath (Get-LedgerIntegrityPath -YearDir $YearDir) -PathType Leaf)) { [void](Protect-LedgerYearContent -YearDir $YearDir -Closing:$Closing) return } $chain = Read-LedgerIntegrityChain -YearDir $YearDir $records = @(Get-LedgerYearMetadataRecord -YearDir $YearDir -Chain $chain -Changed $Name -Closing:$Closing) Write-LedgerIntegrityRecord -YearDir $YearDir -Record $records -Chain $chain } finally { Exit-LedgerJournalLock -Lock $journalLock } } function Test-LedgerIntegrityAttachmentSealed { [CmdletBinding()] [OutputType([bool])] param ( [Parameter(Mandatory)] [string]$YearDir, [Parameter(Mandatory)] [string]$Key ) $chain = Read-LedgerIntegrityChain -YearDir $YearDir if ($null -eq $chain) { return $false } return (Get-LedgerIntegrityState -Chain $chain).Attachments.Contains($Key) } function Remove-LedgerIntegrityTail { <# .SYNOPSIS Drops the last chain record if it seals the given item (rollback of a failed write). #> [CmdletBinding()] param ( [Parameter(Mandatory)] [string]$YearDir, [Parameter(Mandatory)] [string]$Kind, [Parameter(Mandatory)] [string]$Key ) $journalLock = $null try { $journalLock = Enter-LedgerJournalLock -JournalPath (Split-Path -Path $YearDir -Parent) $chain = Read-LedgerIntegrityChain -YearDir $YearDir if ($null -eq $chain -or $chain.Count -eq 0) { return } $last = $chain[$chain.Count - 1] if (-not $last.WellFormed -or $last.Kind -ne $Kind -or $last.Key -ne $Key) { return } $path = Get-LedgerIntegrityPath -YearDir $YearDir $lines = [System.IO.File]::ReadAllLines($path, [System.Text.UTF8Encoding]::new($false)) $kept = for ($i = 0; $i -lt $lines.Count; $i++) { if ($i -ne $last.LineNumber - 1) { $lines[$i] } } Set-LedgerFileContent -Path $path -Value @($kept) } finally { Exit-LedgerJournalLock -Lock $journalLock } } |