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 and removed
# attachment. 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')

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 and the live sealed attachments.
    #>

    [CmdletBinding()]
    param ([Parameter()] [AllowNull()] [object]$Chain)

    $verifications = [ordered]@{}
    $attachments = [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) }
        }
    }
    return [pscustomobject]@{ Verifications = $verifications; Attachments = $attachments }
}

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,

        [Parameter()]
        [AllowNull()]
        [object]$Chain
    )
    if ($Record.Count -eq 0) { return }

    $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 ($null -eq $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))
}

function Protect-LedgerYearContent {
    <#
    .SYNOPSIS
    Seals every verification and attachment in a year that the chain lacks.
    #>

    [CmdletBinding()]
    param ([Parameter(Mandatory)] [string]$YearDir)

    $chain = Read-LedgerIntegrityChain -YearDir $YearDir
    $state = Get-LedgerIntegrityState -Chain $chain
    $content = Get-LedgerYearContent -YearDir $YearDir

    $records = [System.Collections.Generic.List[object]]::new()
    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
}

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.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [string]$YearDir,
        [Parameter(Mandatory)] [ValidateSet('Verification', 'Attachment')] [string]$Kind,
        [Parameter(Mandatory)] [string]$Key,
        [Parameter(Mandatory)] [string]$Path
    )
    if (-not (Test-Path -LiteralPath (Get-LedgerIntegrityPath -YearDir $YearDir) -PathType Leaf)) {
        [void](Protect-LedgerYearContent -YearDir $YearDir)
        return
    }
    $record = [pscustomobject]@{ Kind = $Kind; Key = $Key; Hash = (Get-LedgerContentHash -Path $Path) }
    Write-LedgerIntegrityRecord -YearDir $YearDir -Record @($record)
}

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
    )
    $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)
}