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