com.github.copilot/hooks/scripts/Invoke-LedgerAppender.ps1
|
<#
.SYNOPSIS Append-only, hash-chained ledger service for the specification completion workflow. .DESCRIPTION The /complete-specifications controller may not be trusted to keep its own audit trail: it is the component under audit. This script is the pinned appender the containment profile names. It owns the ledger sink and the protected chain head, and it is the only writer of both. Callers reach it over an authenticated local named pipe and can only append; they cannot rewrite, reorder, truncate, or delete what is already stored. Every record is assigned a monotonic sequence, the previous record's digest, and its own digest over a canonical serialization. A record is flushed to disk before the append is acknowledged, so an acknowledged record always survives a crash. The reverse does not hold: a crash, a lock, or an I/O error between the sink flush and the head write can leave a record that is stored but was never acknowledged. That is why the sequence is spent when the record becomes durable rather than when it is acknowledged, why a head that cannot be written stops the service, and why a restart refuses to serve unless the head names the last record or the one before it. Verification recomputes the whole chain from genesis and compares the final digest with the protected head, so a rewritten body, a removed line, a reordered line, or an edited head all fail. This script stores evidence. It does not create the operating-system boundary that keeps the sink out of the controller's writable set; that is filesystem policy, measured separately by Test-RunContainment.ps1. .PARAMETER Serve Runs the appender service. This is the only mode that writes. Windows only: the pipe cannot be restricted or authenticated anywhere else. .PARAMETER Append Sends one record to a running appender and returns its assigned sequence. The server is authenticated before the record is sent, and the acknowledgement is checked against the ledger before success is reported. .PARAMETER Seal Appends a final record and refuses every later append. Sealing is a server decision carried by the request, not by the record's own type. .PARAMETER Validate Recomputes the chain from genesis and compares it with the protected head. Reads only; safe to run from any identity that can read the sink. Bound to a containment profile with -ProfilePath and -ProfileSha256, or given free paths, which it reports as unbound. .PARAMETER PipeName Local named pipe the service listens on and clients connect to. .PARAMETER SinkPath Append-only JSONL ledger. One record per line. .PARAMETER ChainHeadPath Protected head naming the newest sequence and digest. .PARAMETER RecordJson One record as JSON. Exactly type, runId, utc, and data; nothing else. Keys are case-sensitive, utc is an ISO-8601 instant in UTC, and every number is a JSON integer within +-(2^53-1). .PARAMETER ProfilePath Containment profile naming the ledger. Its ledgerSink and chainHead are the only paths the bound check reads, and its ledgerAppender must be this file. .PARAMETER ProfileSha256 Lowercase 64-character hexadecimal SHA-256 of the profile, computed by the caller. It is the trust anchor and must match the bytes that are parsed. .PARAMETER RequireSealed Additionally requires the chain's last record to be the seal record and the chain head to say so. .PARAMETER ExpectedServerIdentity Identity the client requires the pipe to belong to. Defaults to the principal that would own objects this client creates. .PARAMETER IdleTimeoutSeconds Stops the service after this long without a connection. .PARAMETER AllowedIdentity Windows identity permitted to open the pipe, in addition to the appender's own account. Defaults to the appender's account alone. .EXAMPLE Invoke-LedgerAppender.ps1 -Serve -PipeName ca-ledger-run1 -SinkPath D:\ledger\run1.jsonl -ChainHeadPath D:\ledger\run1.head.json .EXAMPLE Invoke-LedgerAppender.ps1 -Append -PipeName ca-ledger-run1 -RecordJson '{"type":"Baseline","runId":"run1","utc":"2026-09-02T22:00:00Z","data":{}}' -SinkPath D:\ledger\run1.jsonl -ChainHeadPath D:\ledger\run1.head.json .EXAMPLE Invoke-LedgerAppender.ps1 -Validate -ProfilePath D:\profile\containment.json -ProfileSha256 $digest -RequireSealed .EXAMPLE Invoke-LedgerAppender.ps1 -Validate -SinkPath D:\ledger\run1.jsonl -ChainHeadPath D:\ledger\run1.head.json .NOTES Exit codes: 0 success, 2 refusal or verification failure. A refusal is always a hard failure; the caller must never treat it as a warning. #> [CmdletBinding(DefaultParameterSetName = 'Validate')] [OutputType([string])] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Justification = 'The mode switches exist to select a parameter set; the dispatch below reads $PSCmdlet.ParameterSetName.')] param( [Parameter(Mandatory, ParameterSetName = 'Serve')] [switch]$Serve, [Parameter(Mandatory, ParameterSetName = 'Append')] [switch]$Append, [Parameter(Mandatory, ParameterSetName = 'Seal')] [switch]$Seal, [Parameter(Mandatory, ParameterSetName = 'Validate')] [Parameter(Mandatory, ParameterSetName = 'ValidateBound')] [switch]$Validate, [Parameter(Mandatory, ParameterSetName = 'ValidateBound')] [ValidateNotNullOrEmpty()] [string]$ProfilePath, [Parameter(Mandatory, ParameterSetName = 'ValidateBound')] [ValidateNotNullOrEmpty()] [string]$ProfileSha256, [Parameter(ParameterSetName = 'Validate')] [Parameter(ParameterSetName = 'ValidateBound')] [switch]$RequireSealed, [Parameter(Mandatory, ParameterSetName = 'Serve')] [Parameter(Mandatory, ParameterSetName = 'Append')] [Parameter(Mandatory, ParameterSetName = 'Seal')] [ValidateNotNullOrEmpty()] [string]$PipeName, [Parameter(Mandatory, ParameterSetName = 'Serve')] [Parameter(Mandatory, ParameterSetName = 'Append')] [Parameter(Mandatory, ParameterSetName = 'Seal')] [Parameter(Mandatory, ParameterSetName = 'Validate')] [ValidateNotNullOrEmpty()] [string]$SinkPath, [Parameter(Mandatory, ParameterSetName = 'Serve')] [Parameter(Mandatory, ParameterSetName = 'Append')] [Parameter(Mandatory, ParameterSetName = 'Seal')] [Parameter(Mandatory, ParameterSetName = 'Validate')] [ValidateNotNullOrEmpty()] [string]$ChainHeadPath, [Parameter(Mandatory, ParameterSetName = 'Append')] [Parameter(Mandatory, ParameterSetName = 'Seal')] [ValidateNotNullOrEmpty()] [string]$RecordJson, [Parameter(ParameterSetName = 'Append')] [Parameter(ParameterSetName = 'Seal')] [AllowEmptyString()] [string]$ExpectedServerIdentity, [Parameter(ParameterSetName = 'Serve')] [ValidateRange(1, 86400)] [int]$IdleTimeoutSeconds = 3600, [Parameter(ParameterSetName = 'Serve')] [AllowEmptyString()] [string]$AllowedIdentity ) Set-StrictMode -Version 3.0 $ErrorActionPreference = 'Stop' trap { <# Every mode reports a refusal the same way: one line on standard error and exit 2. An unexpected failure must not become an exit code of its own or a stack trace on the stream a caller parses, so anything that escapes a local handler lands here. Explicit exits are flow control, not errors, and pass through untouched. #> Write-Refusal -Message $_.Exception.GetBaseException().Message exit 2 } $script:GenesisHash = '0' * 64 $script:RequiredField = @('type', 'runId', 'utc', 'data') $script:RequestField = @('operation', 'record') $script:Operation = @('Append', 'Seal') $script:StoredField = @('data', 'prevSha256', 'recordSha256', 'runId', 'sequence', 'type', 'utc') $script:HeadField = @('recordSha256', 'sealed', 'sequence', 'updatedUtc') # Every name that arrives from outside is matched against these sets, never with # -ceq or -cin. PowerShell's case-sensitive operators are still culture-ordered: # a character the collation ignores, such as a zero-width space, compares equal # to nothing at all, so 'ty<U+200B>pe' -ceq 'type' is $true and a closed grammar # is only closed up to culture equivalence. $script:RequiredFieldSet = [System.Collections.Generic.HashSet[string]]::new([string[]]$script:RequiredField, [System.StringComparer]::Ordinal) $script:RequestFieldSet = [System.Collections.Generic.HashSet[string]]::new([string[]]$script:RequestField, [System.StringComparer]::Ordinal) $script:OperationSet = [System.Collections.Generic.HashSet[string]]::new([string[]]$script:Operation, [System.StringComparer]::Ordinal) $script:StoredFieldSet = [System.Collections.Generic.HashSet[string]]::new([string[]]$script:StoredField, [System.StringComparer]::Ordinal) $script:HeadFieldSet = [System.Collections.Generic.HashSet[string]]::new([string[]]$script:HeadField, [System.StringComparer]::Ordinal) $script:Utf8 = [System.Text.UTF8Encoding]::new($false) $script:StrictUtf8 = [System.Text.UTF8Encoding]::new($false, $true) # \A and \z, never ^ and $: $ also matches in front of a final line feed, so an # anchored-looking pattern would accept a digest or an instant with one appended. $script:DigestPattern = '\A[0-9a-f]{64}\z' $script:UtcPattern = '\A[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,9})?Z\z' $script:IntegerPattern = '\A-?(0|[1-9][0-9]*)\z' $script:SidPattern = '\AS-1-[0-9-]+\z' # The shape is not the instant: 2026-99-99T99:99:99Z has the shape. .NET parses # at most seven fractional digits, so a longer fraction is checked for shape and # parsed with the fraction cut to seven. The value itself is never rewritten. $script:UtcFormat = [string[]]@("yyyy-MM-dd'T'HH:mm:ss'Z'", "yyyy-MM-dd'T'HH:mm:ss.FFFFFFF'Z'") $script:UtcStyle = [System.Globalization.DateTimeStyles]::AssumeUniversal -bor [System.Globalization.DateTimeStyles]::AdjustToUniversal $script:LongFractionPattern = '\A(?<kept>[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{7})[0-9]{1,2}Z\z' $script:SafeInteger = 9007199254740991 $script:MaxRequestLength = 262144 # A refusal line is read by the controller, which is a model, and its text can # come from whoever answered the pipe. The rule is an allow-list, not a list of # categories to drop: a category filter keeps variation selectors, Hangul # fillers, the Braille blank and the various spaces, every one of which carries # data as invisibly as a tag character. Only printable ASCII survives. $script:RefusalFirstRune = 0x20 $script:RefusalLastRune = 0x7E $script:RefusalLength = 200 $script:OnWindows = $IsWindows -or $PSVersionTable.PSEdition -eq 'Desktop' $script:FatalError = '' $script:JsonOption = [System.Text.Json.JsonDocumentOptions]::new() $script:JsonOption.MaxDepth = 32 function Test-OrdinalEqual { <# The only string equality this script uses on text from outside it. [string]::Equals with StringComparison.Ordinal compares code units, so no character is ignorable and no culture can make two different names the same name. #> [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory)] [AllowEmptyString()] [AllowNull()] [string]$Left, [Parameter(Mandatory)] [AllowEmptyString()] [AllowNull()] [string]$Right ) return [string]::Equals($Left, $Right, [System.StringComparison]::Ordinal) } function Test-UtcInstant { <# An ISO-8601 instant in UTC, and a date that exists: the pattern alone accepts the thirtieth of February and the twenty-fourth hour. Parsing is invariant and exact, so nothing is inferred from the host's culture, and the caller keeps the original text to store. #> [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory)] [AllowEmptyString()] [AllowNull()] [string]$Value ) if ($null -eq $Value -or $Value -cnotmatch $script:UtcPattern) { return $false } $candidate = $Value $long = [regex]::Match($Value, $script:LongFractionPattern) if ($long.Success) { $candidate = $long.Groups['kept'].Value + 'Z' } $parsed = [datetimeoffset]::MinValue return [System.DateTimeOffset]::TryParseExact($candidate, $script:UtcFormat, [System.Globalization.CultureInfo]::InvariantCulture, $script:UtcStyle, [ref]$parsed) } function Format-RefusalText { <# Makes any text safe to put on one line of standard error for a reader that cannot see what it is given. A rune survives only if it is printable ASCII; everything else becomes one question mark, accented letters and emoji included. Deciding by Unicode category was not enough: a variation selector, a Hangul filler, the Braille blank and the wider spaces are all letters, symbols or separators by category and carry data as invisibly as a tag character does. The enumeration is by rune rather than by UTF-16 unit, so an astral character becomes one question mark and not two, and the cut moves whole runes, so no surrogate pair is ever split in half. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [AllowEmptyString()] [AllowNull()] [string]$Text ) if ([string]::IsNullOrEmpty($Text)) { return '' } $builder = [System.Text.StringBuilder]::new() $cut = $false foreach ($rune in $Text.EnumerateRunes()) { $piece = if ($rune.Value -ge $script:RefusalFirstRune -and $rune.Value -le $script:RefusalLastRune) { $rune.ToString() } else { '?' } if ($builder.Length + $piece.Length -gt $script:RefusalLength) { $cut = $true break } $null = $builder.Append($piece) } if ($cut) { $null = $builder.Append('...') } return $builder.ToString() } function Write-Refusal { [CmdletBinding()] param( [Parameter(Mandatory)] [AllowEmptyString()] [string]$Message ) [Console]::Error.WriteLine("Ledger appender refused: $(Format-RefusalText -Text $Message)") } function ConvertFrom-JsonText { <# System.Text.Json is the only parser used here. ConvertFrom-Json coerces an ISO-8601 string into a DateTime, a large integer into a BigInteger, and folds two keys that differ only in case into one, so the preimage would depend on the parser, the host culture and the PowerShell version instead of on the bytes the caller actually sent. #> [CmdletBinding()] [OutputType([System.Text.Json.JsonDocument])] param( [Parameter(Mandatory)] [AllowEmptyString()] [string]$Text ) return [System.Text.Json.JsonDocument]::Parse($Text, $script:JsonOption) } function ConvertTo-JsonStringLiteral { <# JsonEncodedText is the non-generic escaper: PowerShell cannot resolve the generic JsonSerializer.Serialize<T> overload, and ConvertTo-Json has changed its escaping between releases, which would silently break every digest written by an older version. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [AllowEmptyString()] [string]$Value ) return '"' + [System.Text.Json.JsonEncodedText]::Encode($Value).ToString() + '"' } function ConvertTo-CanonicalNumber { <# Only a JSON integer token inside +-(2^53-1) is representable identically by every reader the evidence may pass through. A fraction, an exponent, or a wider integer is refused at append time rather than stored in a form that re-reads as a different value and breaks its own digest. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [string]$Raw ) if ($Raw -cnotmatch $script:IntegerPattern) { throw "number '$Raw' is not a JSON integer token" } $value = [long]0 $parsed = [long]::TryParse($Raw, [System.Globalization.NumberStyles]::AllowLeadingSign, [cultureinfo]::InvariantCulture, [ref]$value) if (-not $parsed -or $value -gt $script:SafeInteger -or $value -lt (-$script:SafeInteger)) { throw "number '$Raw' is outside the supported range of +-(2^53-1)" } return $value.ToString([cultureinfo]::InvariantCulture) } function Get-CanonicalMember { <# Returns the members of a JSON object as name -> canonical value text. The dictionary compares names with ordinal case sensitivity, because 'a' and 'A' are two different JSON keys and must stay two. #> [CmdletBinding()] [OutputType([System.Collections.Specialized.OrderedDictionary])] param( [Parameter(Mandatory)] [System.Text.Json.JsonElement]$Element ) $member = [System.Collections.Specialized.OrderedDictionary]::new([System.StringComparer]::Ordinal) foreach ($property in $Element.EnumerateObject()) { if ($member.Contains($property.Name)) { # System.Text.Json keeps both members of a duplicate key and every # reader picks one of them, so the text a reader shows and the text # a digest covers would differ. There is no safe resolution. throw "duplicate key '$($property.Name)'" } $member[$property.Name] = ConvertTo-CanonicalJson -Element $property.Value } return $member } function Join-CanonicalMember { <# [string]::CompareOrdinal, not Sort-Object: the default comparison is culture-ordered, so the same record would serialize differently under da-DK than under en-US and fail its own digest on another host. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [System.Collections.IDictionary]$Member ) $name = [string[]]@($Member.Keys) [array]::Sort($name, [System.StringComparer]::Ordinal) $pair = foreach ($key in $name) { (ConvertTo-JsonStringLiteral -Value $key) + ':' + $Member[$key] } return '{' + ($pair -join ',') + '}' } function ConvertTo-CanonicalJson { <# A digest is only meaningful over bytes both sides derive the same way, so the canonical form is a pure function of the parsed data: keys sorted ordinal at every depth, no insignificant whitespace, one escaping, one number format, and no type coercion of any kind. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [System.Text.Json.JsonElement]$Element ) switch ($Element.ValueKind) { ([System.Text.Json.JsonValueKind]::Object) { return Join-CanonicalMember -Member (Get-CanonicalMember -Element $Element) } ([System.Text.Json.JsonValueKind]::Array) { $item = foreach ($child in $Element.EnumerateArray()) { ConvertTo-CanonicalJson -Element $child } return '[' + ($item -join ',') + ']' } ([System.Text.Json.JsonValueKind]::String) { return ConvertTo-JsonStringLiteral -Value $Element.GetString() } ([System.Text.Json.JsonValueKind]::Number) { return ConvertTo-CanonicalNumber -Raw $Element.GetRawText() } ([System.Text.Json.JsonValueKind]::True) { return 'true' } ([System.Text.Json.JsonValueKind]::False) { return 'false' } ([System.Text.Json.JsonValueKind]::Null) { return 'null' } } throw "unsupported JSON value kind '$($Element.ValueKind)'" } function Get-Sha256HexFromByte { [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [AllowEmptyCollection()] [byte[]]$Byte ) $sha256 = [System.Security.Cryptography.SHA256]::Create() try { return [System.Convert]::ToHexString($sha256.ComputeHash($Byte)).ToLowerInvariant() } finally { $sha256.Dispose() } } function Get-Sha256Hex { [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [AllowEmptyString()] [string]$Text ) $sha256 = [System.Security.Cryptography.SHA256]::Create() try { $hash = $sha256.ComputeHash($script:Utf8.GetBytes($Text)) return [System.Convert]::ToHexString($hash).ToLowerInvariant() } finally { $sha256.Dispose() } } function New-StoredRecord { <# The stored line is the canonical serialization of the caller's record with sequence, prevSha256 and recordSha256 added, so every byte on disk is covered by the chain except the digest itself, which cannot cover itself. Key order is the ordinal order of the names, which puts them at data, prevSha256, recordSha256, runId, sequence, type, utc. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Builds an in-memory record; the caller decides when anything is written.')] [CmdletBinding()] [OutputType([hashtable])] param( [Parameter(Mandatory)] [System.Text.Json.JsonElement]$Record, [Parameter(Mandatory)] [int]$Sequence, [Parameter(Mandatory)] [string]$PreviousHash ) $member = Get-CanonicalMember -Element $Record $member['sequence'] = ([long]$Sequence).ToString([cultureinfo]::InvariantCulture) $member['prevSha256'] = ConvertTo-JsonStringLiteral -Value $PreviousHash $preimage = Join-CanonicalMember -Member $member $digest = Get-Sha256Hex -Text $preimage $member['recordSha256'] = ConvertTo-JsonStringLiteral -Value $digest return @{ Line = Join-CanonicalMember -Member $member RecordSha256 = $digest Preimage = $preimage } } function Test-RecordSchema { <# The record schema is closed. An unknown field is refused rather than ignored, so a caller cannot smuggle a command, path, or authorization claim through the ledger into anything that later reads it. Field names are compared case-sensitively, because 'Type' is not 'type'. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [System.Text.Json.JsonElement]$Record ) if ($Record.ValueKind -ne [System.Text.Json.JsonValueKind]::Object) { return 'record must be a JSON object' } $present = [System.Collections.Specialized.OrderedDictionary]::new([System.StringComparer]::Ordinal) foreach ($property in $Record.EnumerateObject()) { if (-not $script:RequiredFieldSet.Contains($property.Name)) { return "unknown field '$($property.Name)'" } if ($present.Contains($property.Name)) { return "duplicate field '$($property.Name)'" } $present[$property.Name] = $property.Value } foreach ($required in $script:RequiredField) { if (-not $present.Contains($required)) { return "missing required field '$required'" } } foreach ($name in @('type', 'runId', 'utc')) { $value = [System.Text.Json.JsonElement]$present[$name] if ($value.ValueKind -ne [System.Text.Json.JsonValueKind]::String) { return "field '$name' must be a string" } if ([string]::IsNullOrWhiteSpace($value.GetString())) { return "field '$name' must be a non-empty string" } } if (([System.Text.Json.JsonElement]$present['data']).ValueKind -ne [System.Text.Json.JsonValueKind]::Object) { return "field 'data' must be a JSON object" } if (-not (Test-UtcInstant -Value ([System.Text.Json.JsonElement]$present['utc']).GetString())) { return "field 'utc' must be an ISO-8601 instant in UTC, for example 2026-09-02T22:00:00Z" } try { $null = ConvertTo-CanonicalJson -Element $Record } catch { return "record is not canonically representable: $($_.Exception.Message)" } return '' } function Read-StoredRecord { <# Closes the stored line. The line must parse, carry exactly the seven fields of the stored schema once each, and be byte-for-byte the canonical serialization of what it parses to. Only then is its digest recomputed over the stored bytes, minus the digest member, which cannot cover itself. Anything a reader would see is therefore covered: no extra field, no duplicate key, no reordering, and no spare whitespace can carry text the chain does not hash. #> [CmdletBinding()] [OutputType([hashtable])] param( [Parameter(Mandatory)] [AllowEmptyString()] [string]$Text ) $record = @{ Error = '' Sequence = 0 PreviousHash = '' RecordSha256 = '' Type = '' ComputedDigest = '' } try { $document = ConvertFrom-JsonText -Text $Text } catch { $record.Error = 'is not valid JSON' return $record } try { $root = $document.RootElement if ($root.ValueKind -ne [System.Text.Json.JsonValueKind]::Object) { $record.Error = 'is not a JSON object' return $record } try { $member = Get-CanonicalMember -Element $root } catch { $record.Error = "is not canonically representable: $($_.Exception.Message)" return $record } foreach ($name in @($member.Keys)) { if (-not $script:StoredFieldSet.Contains($name)) { $record.Error = "carries the unknown field '$name'" return $record } } foreach ($required in $script:StoredField) { if (-not $member.Contains($required)) { $record.Error = "is missing '$required'" return $record } } if (-not (Test-OrdinalEqual -Left (Join-CanonicalMember -Member $member) -Right $Text)) { $record.Error = 'is not stored in canonical form' return $record } if ([string]$member['sequence'] -cnotmatch $script:IntegerPattern) { $record.Error = 'declares a sequence that is not a JSON integer' return $record } foreach ($name in @('prevSha256', 'recordSha256')) { $value = $root.GetProperty($name) if ($value.ValueKind -ne [System.Text.Json.JsonValueKind]::String -or $value.GetString() -cnotmatch $script:DigestPattern) { $record.Error = "declares a $name that is not a lowercase 64-character hexadecimal digest" return $record } } $type = $root.GetProperty('type') if ($type.ValueKind -ne [System.Text.Json.JsonValueKind]::String) { $record.Error = 'declares a type that is not a string' return $record } $preimageMember = [System.Collections.Specialized.OrderedDictionary]::new([System.StringComparer]::Ordinal) foreach ($key in @($member.Keys)) { if (-not (Test-OrdinalEqual -Left $key -Right 'recordSha256')) { $preimageMember[$key] = $member[$key] } } $record.Sequence = [long]$member['sequence'] $record.PreviousHash = $root.GetProperty('prevSha256').GetString() $record.RecordSha256 = $root.GetProperty('recordSha256').GetString() $record.Type = $type.GetString() $record.ComputedDigest = Get-Sha256Hex -Text (Join-CanonicalMember -Member $preimageMember) } finally { $document.Dispose() } return $record } function Read-ContainmentProfile { <# The profile is read exactly once and the digest given on the command line is the trust anchor: a digest the run derives from the file it is about to trust proves nothing. The bytes that are hashed are the bytes that are parsed, so the paths this returns cannot come from a document the caller swapped in afterwards. #> [CmdletBinding()] [OutputType([hashtable])] param( [Parameter(Mandatory)] [string]$Path, [Parameter(Mandatory)] [string]$Sha256 ) $containmentProfile = @{ Error = '' SinkPath = '' ChainHeadPath = '' AppenderPath = '' AppenderSha256 = '' } if ($Sha256 -cnotmatch $script:DigestPattern) { $containmentProfile.Error = 'the profile digest must be a lowercase 64-character hexadecimal string' return $containmentProfile } if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { $containmentProfile.Error = "the containment profile '$Path' does not exist" return $containmentProfile } $bytes = Read-FileByte -Path $Path if (-not (Test-OrdinalEqual -Left (Get-Sha256HexFromByte -Byte $bytes) -Right $Sha256)) { $containmentProfile.Error = 'the containment profile does not match the digest given on the command line' return $containmentProfile } try { $text = $script:StrictUtf8.GetString($bytes) } catch { $containmentProfile.Error = 'the containment profile is not valid UTF-8' return $containmentProfile } # A byte-order mark is part of the hashed bytes but is not JSON; the # verifier reads the same file through a reader that skips it. $text = $text.TrimStart([char]0xFEFF) try { $document = ConvertFrom-JsonText -Text $text } catch { $containmentProfile.Error = 'the containment profile is not valid JSON' return $containmentProfile } try { $root = $document.RootElement if ($root.ValueKind -ne [System.Text.Json.JsonValueKind]::Object) { $containmentProfile.Error = 'the containment profile is not a JSON object' return $containmentProfile } foreach ($name in @('ledgerSink', 'chainHead')) { $value = [System.Text.Json.JsonElement]::new() if (-not $root.TryGetProperty($name, [ref]$value) -or $value.ValueKind -ne [System.Text.Json.JsonValueKind]::String -or [string]::IsNullOrWhiteSpace($value.GetString())) { $containmentProfile.Error = "the containment profile does not declare '$name' as a path" return $containmentProfile } if (Test-OrdinalEqual -Left $name -Right 'ledgerSink') { $containmentProfile.SinkPath = $value.GetString() } else { $containmentProfile.ChainHeadPath = $value.GetString() } } $appender = [System.Text.Json.JsonElement]::new() if (-not $root.TryGetProperty('ledgerAppender', [ref]$appender) -or $appender.ValueKind -ne [System.Text.Json.JsonValueKind]::Object) { $containmentProfile.Error = "the containment profile does not declare 'ledgerAppender'" return $containmentProfile } $appenderPath = [System.Text.Json.JsonElement]::new() $appenderDigest = [System.Text.Json.JsonElement]::new() if (-not $appender.TryGetProperty('path', [ref]$appenderPath) -or $appenderPath.ValueKind -ne [System.Text.Json.JsonValueKind]::String -or [string]::IsNullOrWhiteSpace($appenderPath.GetString())) { $containmentProfile.Error = "the containment profile does not declare 'ledgerAppender.path'" return $containmentProfile } if (-not $appender.TryGetProperty('sha256', [ref]$appenderDigest) -or $appenderDigest.ValueKind -ne [System.Text.Json.JsonValueKind]::String -or $appenderDigest.GetString() -cnotmatch $script:DigestPattern) { $containmentProfile.Error = "the containment profile does not declare 'ledgerAppender.sha256' as a lowercase 64-character hexadecimal digest" return $containmentProfile } $containmentProfile.AppenderPath = $appenderPath.GetString() $containmentProfile.AppenderSha256 = $appenderDigest.GetString() } finally { $document.Dispose() } return $containmentProfile } function Test-PinnedAppender { <# Binding the verdict to the profile is only worth something if the script producing it is the one the profile pins, so the running file is compared with ledgerAppender both by path and by digest. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [string]$Path, [Parameter(Mandatory)] [string]$Sha256 ) if ([string]::IsNullOrWhiteSpace($PSCommandPath)) { return 'the running appender has no file path to compare with the pinned one' } if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return "the pinned appender '$Path' does not exist" } $pinned = [System.IO.Path]::GetFullPath($Path) $running = [System.IO.Path]::GetFullPath($PSCommandPath) $comparison = if ($script:OnWindows) { [System.StringComparison]::OrdinalIgnoreCase } else { [System.StringComparison]::Ordinal } if (-not [string]::Equals($pinned, $running, $comparison)) { return "the running appender '$running' is not the pinned appender '$pinned'" } $digest = Get-Sha256HexFromByte -Byte (Read-FileByte -Path $running) if (-not (Test-OrdinalEqual -Left $digest -Right $Sha256)) { return 'the running appender does not match the digest the profile pins' } return '' } function Read-FileByte { <# The service holds the sink open for its whole life, so every reader here has to tolerate that writer. File.ReadAllBytes asks for a share mode that does not, and would turn a running appender into a verification failure. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '', Justification = 'The comma wraps the array so PowerShell cannot unroll it; the caller receives the byte[] the attribute declares.')] [CmdletBinding()] [OutputType([byte[]])] param( [Parameter(Mandatory)] [string]$Path ) $stream = [System.IO.FileStream]::new($Path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite) try { $buffer = [byte[]]::new($stream.Length) $read = 0 while ($read -lt $buffer.Length) { $count = $stream.Read($buffer, $read, $buffer.Length - $read) if ($count -le 0) { break } $read += $count } # The comma keeps an empty or single-byte buffer an array: PowerShell # would otherwise unroll it to $null or to one byte. return , $buffer } finally { $stream.Dispose() } } function Read-RequestLine { <# One request is one line. The cap is there because the reader is fed by an untrusted client: without it, a client that never sends a newline grows the buffer until the service dies, taking the run's evidence with it. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [System.IO.StreamReader]$Reader ) $builder = [System.Text.StringBuilder]::new() while ($true) { $value = $Reader.Read() if ($value -lt 0 -or $value -eq 10) { break } if ($value -eq 13) { continue } if ($builder.Length -ge $script:MaxRequestLength) { throw "request exceeds $($script:MaxRequestLength) characters" } $null = $builder.Append([char]$value) } return $builder.ToString() } function Read-SinkLine { <# Reads the sink as bytes rather than as text lines. A trailing record without its terminator is a half-written append, not a record; a blank line, a CR, or a byte-order mark is text nobody hashed. Each is refused instead of skipped, because skipping is what lets a writer park content between the records. #> [CmdletBinding()] [OutputType([hashtable])] param( [Parameter(Mandatory)] [string]$Path ) $result = @{ Error = ''; Line = [string[]]@() } $bytes = Read-FileByte -Path $Path if ($bytes.Length -eq 0) { return $result } if ($bytes[0] -eq 0xEF -and $bytes.Length -ge 3 -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { $result.Error = 'the ledger sink starts with a byte-order mark' return $result } if ($bytes[$bytes.Length - 1] -ne 0x0A) { $result.Error = 'the ledger sink does not end with a complete, terminated record' return $result } try { $text = $script:StrictUtf8.GetString($bytes) } catch { $result.Error = 'the ledger sink is not valid UTF-8' return $result } $segment = $text.Split("`n") $line = [System.Collections.Generic.List[string]]::new() for ($index = 0; $index -lt $segment.Length - 1; $index++) { if ($segment[$index].Length -eq 0) { $result.Error = "the ledger sink holds an empty line at position $($index + 1)" return $result } $line.Add($segment[$index]) } $result.Line = $line.ToArray() return $result } function Read-ChainState { <# Rebuilds sequence, last digest, and sealed state by verifying the whole chain from genesis. It reads the sink only: whether that chain is the one the protected head names is decided separately, by Test-HeadAgreement, so that a restart cannot resume onto a chain that was shortened or rewritten while the service was down. #> [CmdletBinding()] [OutputType([hashtable])] param( [Parameter(Mandatory)] [string]$Path, [Parameter(Mandatory)] [string]$HeadPath ) $state = @{ Sequence = 0 LastHash = $script:GenesisHash PreviousHash = $script:GenesisHash Sealed = $false Error = '' } if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { if (Test-Path -LiteralPath $HeadPath -PathType Leaf) { $state.Error = 'chain head exists without a ledger sink' } return $state } $sink = Read-SinkLine -Path $Path if ($sink.Error) { $state.Error = $sink.Error return $state } $expectedPrevious = $script:GenesisHash $priorHash = $script:GenesisHash $sequence = 0 foreach ($text in $sink.Line) { $sequence++ $record = Read-StoredRecord -Text $text if ($record.Error) { $state.Error = "record at sequence $sequence $($record.Error)" return $state } if ($state.Sealed) { $state.Error = "record at sequence $sequence follows the seal record" return $state } if ($record.Sequence -ne $sequence) { $state.Error = "record at sequence $sequence declares sequence $($record.Sequence)" return $state } if (-not (Test-OrdinalEqual -Left $record.PreviousHash -Right $expectedPrevious)) { $state.Error = "record at sequence $sequence does not chain to its predecessor" return $state } if (-not (Test-OrdinalEqual -Left $record.RecordSha256 -Right $record.ComputedDigest)) { $state.Error = "record at sequence $sequence fails its own digest" return $state } if (Test-OrdinalEqual -Left $record.Type -Right 'Seal') { $state.Sealed = $true } $priorHash = $expectedPrevious $expectedPrevious = $record.RecordSha256 } $state.Sequence = $sequence $state.LastHash = $expectedPrevious $state.PreviousHash = $priorHash return $state } function Read-ChainHeadState { <# The head is evidence too, so it is read with the same strictness as a record: exactly four members, canonical bytes, a lowercase digest and a sequence that is a JSON integer. An unreadable head is a refusal, never a reason to fall back on the sink. #> [CmdletBinding()] [OutputType([hashtable])] param( [Parameter(Mandatory)] [string]$Path ) $head = @{ Present = $false Sequence = 0 RecordSha256 = $script:GenesisHash Sealed = $false Error = '' } if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $head } $head.Present = $true $bytes = Read-FileByte -Path $Path if ($bytes.Length -eq 0) { $head.Error = 'the chain head is empty' return $head } try { $text = $script:StrictUtf8.GetString($bytes) } catch { $head.Error = 'the chain head is not valid UTF-8' return $head } try { $document = ConvertFrom-JsonText -Text $text } catch { $head.Error = 'the chain head is not valid JSON' return $head } try { $root = $document.RootElement if ($root.ValueKind -ne [System.Text.Json.JsonValueKind]::Object) { $head.Error = 'the chain head is not a JSON object' return $head } try { $member = Get-CanonicalMember -Element $root } catch { $head.Error = "the chain head is not canonically representable: $($_.Exception.Message)" return $head } foreach ($name in @($member.Keys)) { if (-not $script:HeadFieldSet.Contains($name)) { $head.Error = "the chain head carries the unknown field '$name'" return $head } } foreach ($required in $script:HeadField) { if (-not $member.Contains($required)) { $head.Error = "the chain head is missing '$required'" return $head } } if (-not (Test-OrdinalEqual -Left (Join-CanonicalMember -Member $member) -Right $text)) { $head.Error = 'the chain head is not stored in canonical form' return $head } $digest = $root.GetProperty('recordSha256') if ($digest.ValueKind -ne [System.Text.Json.JsonValueKind]::String -or $digest.GetString() -cnotmatch $script:DigestPattern) { $head.Error = 'the chain head names a digest that is not a lowercase 64-character hexadecimal string' return $head } $sealed = $root.GetProperty('sealed') if ($sealed.ValueKind -ne [System.Text.Json.JsonValueKind]::True -and $sealed.ValueKind -ne [System.Text.Json.JsonValueKind]::False) { $head.Error = "the chain head's sealed flag is not a JSON boolean" return $head } if ([string]$member['sequence'] -cnotmatch $script:IntegerPattern -or [long]$member['sequence'] -lt 1) { $head.Error = 'the chain head names a sequence that is not a positive JSON integer' return $head } if (($root.GetProperty('updatedUtc')).ValueKind -ne [System.Text.Json.JsonValueKind]::String -or -not (Test-UtcInstant -Value ($root.GetProperty('updatedUtc')).GetString())) { $head.Error = 'the chain head names an updatedUtc that is not an ISO-8601 instant in UTC' return $head } $head.Sequence = [long]$member['sequence'] $head.RecordSha256 = $digest.GetString() $head.Sealed = $sealed.ValueKind -eq [System.Text.Json.JsonValueKind]::True } finally { $document.Dispose() } return $head } function Test-HeadAgreement { <# The head and the sink must agree before the service writes anything. Exactly one disagreement is survivable: a head one record behind the sink, which is what a crash between the sink flush and the head write leaves. Everything else -- a shortened sink, a head naming a record the sink does not end with, a missing head over a longer sink -- is a refusal, because adopting the sink and rewriting the head is precisely how a truncation would be laundered into a valid-looking ledger. #> [CmdletBinding()] [OutputType([hashtable])] param( [Parameter(Mandatory)] [hashtable]$State, [Parameter(Mandatory)] [hashtable]$Head ) $agreement = @{ Error = ''; CatchUp = $false } if (-not $Head.Present) { if ($State.Sequence -eq 0) { return $agreement } if ($State.Sequence -eq 1) { $agreement.CatchUp = $true return $agreement } $agreement.Error = "the chain head is missing while the sink holds $($State.Sequence) records" return $agreement } if ($State.Sequence -eq 0) { $agreement.Error = 'the chain head names a record but the ledger sink holds none' return $agreement } if ($Head.Sequence -eq $State.Sequence) { if (-not (Test-OrdinalEqual -Left $Head.RecordSha256 -Right $State.LastHash)) { $agreement.Error = "the chain head names a different record at sequence $($State.Sequence)" return $agreement } if ($Head.Sealed -ne $State.Sealed) { $agreement.Error = "the chain head's sealed flag contradicts the chain" return $agreement } return $agreement } if ($Head.Sequence -eq $State.Sequence - 1) { if (-not (Test-OrdinalEqual -Left $Head.RecordSha256 -Right $State.PreviousHash)) { $agreement.Error = "the chain head names a different record at sequence $($Head.Sequence)" return $agreement } if ($Head.Sealed) { $agreement.Error = 'the chain head is sealed while the sink holds a later record' return $agreement } $agreement.CatchUp = $true return $agreement } $agreement.Error = "the chain head names sequence $($Head.Sequence) but the ledger sink ends at $($State.Sequence)" return $agreement } function Write-ChainHead { <# Written to a temporary file in the same directory, flushed through the operating-system cache, and then moved over the old head, so a reader never sees a half-written head and a failed write leaves the previous one in place. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Path, [Parameter(Mandatory)] [int]$Sequence, [Parameter(Mandatory)] [string]$RecordHash, [Parameter(Mandatory)] [bool]$Sealed ) $member = [System.Collections.Specialized.OrderedDictionary]::new([System.StringComparer]::Ordinal) $member['sequence'] = ([long]$Sequence).ToString([cultureinfo]::InvariantCulture) $member['recordSha256'] = ConvertTo-JsonStringLiteral -Value $RecordHash $member['updatedUtc'] = ConvertTo-JsonStringLiteral -Value ([datetime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ', [cultureinfo]::InvariantCulture)) $member['sealed'] = if ($Sealed) { 'true' } else { 'false' } $head = Join-CanonicalMember -Member $member $temporary = '{0}.{1}.tmp' -f $Path, [guid]::NewGuid().ToString('N') try { $stream = [System.IO.FileStream]::new($temporary, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None) try { $bytes = $script:Utf8.GetBytes($head) $stream.Write($bytes, 0, $bytes.Length) $stream.Flush($true) } finally { $stream.Dispose() } [System.IO.File]::Move($temporary, $Path, $true) } finally { if (Test-Path -LiteralPath $temporary -PathType Leaf) { Remove-Item -LiteralPath $temporary -Force -ErrorAction SilentlyContinue } } } function Read-AppenderRequest { <# Sealing is the server's decision, so the request says what it wants done, not what the record should look like. An append carrying a record of type 'Seal' is refused rather than honoured: that was how a caller could seal the ledger without ever asking to. #> [CmdletBinding()] [OutputType([hashtable])] param( [Parameter(Mandatory)] [System.Text.Json.JsonElement]$Element ) $request = @{ Error = ''; Operation = ''; Record = [System.Text.Json.JsonElement]::new() } if ($Element.ValueKind -ne [System.Text.Json.JsonValueKind]::Object) { $request.Error = 'request must be a JSON object' return $request } $seen = [System.Collections.Specialized.OrderedDictionary]::new([System.StringComparer]::Ordinal) foreach ($property in $Element.EnumerateObject()) { if (-not $script:RequestFieldSet.Contains($property.Name)) { $request.Error = "request carries the unknown field '$($property.Name)'" return $request } if ($seen.Contains($property.Name)) { $request.Error = "request repeats the field '$($property.Name)'" return $request } $seen[$property.Name] = $property.Value } foreach ($required in $script:RequestField) { if (-not $seen.Contains($required)) { $request.Error = "request is missing '$required'" return $request } } $operation = [System.Text.Json.JsonElement]$seen['operation'] if ($operation.ValueKind -ne [System.Text.Json.JsonValueKind]::String -or -not $script:OperationSet.Contains($operation.GetString())) { $request.Error = "request names an operation that is not one of: $($script:Operation -join ', ')" return $request } $record = [System.Text.Json.JsonElement]$seen['record'] $schemaError = Test-RecordSchema -Record $record if ($schemaError) { $request.Error = $schemaError return $request } $type = ($record.GetProperty('type')).GetString() if (Test-OrdinalEqual -Left $operation.GetString() -Right 'Seal') { if (-not (Test-OrdinalEqual -Left $type -Right 'Seal')) { $request.Error = "a seal operation must carry a record of type 'Seal'" return $request } } elseif (Test-OrdinalEqual -Left $type -Right 'Seal') { $request.Error = "the record type 'Seal' is reserved for the seal operation" return $request } $request.Operation = $operation.GetString() $request.Record = $record return $request } function Add-Record { <# Persist, then advance, then publish. The sink write is flushed through the operating-system cache and the in-memory state advances with it, so a sequence is spent the moment its record is durable and can never be handed to a second record. Publishing the head comes last and is fatal when it fails: the record stays unacknowledged, and the restart check accepts a head exactly one behind and continues from the sink. #> [CmdletBinding()] [OutputType([hashtable])] param( [Parameter(Mandatory)] [System.Text.Json.JsonElement]$Record, [Parameter(Mandatory)] [ValidateSet('Append', 'Seal')] [string]$Operation, [Parameter(Mandatory)] [hashtable]$State, [Parameter(Mandatory)] [System.IO.FileStream]$Stream, [Parameter(Mandatory)] [string]$HeadPath ) $sequence = [int]$State.Sequence + 1 $stored = New-StoredRecord -Record $Record -Sequence $sequence -PreviousHash ([string]$State.LastHash) try { $bytes = $script:Utf8.GetBytes($stored.Line + "`n") $Stream.Write($bytes, 0, $bytes.Length) $Stream.Flush($true) } catch { $script:FatalError = "could not write the ledger sink: $($_.Exception.Message)" throw } # The operation seals, not the record's own type: the server decides. $sealed = [bool]$State.Sealed -or (Test-OrdinalEqual -Left $Operation -Right 'Seal') $State.Sequence = $sequence $State.PreviousHash = [string]$State.LastHash $State.LastHash = $stored.RecordSha256 $State.Sealed = $sealed try { Write-ChainHead -Path $HeadPath -Sequence $sequence -RecordHash $stored.RecordSha256 -Sealed $sealed } catch { $script:FatalError = "could not write the chain head: $($_.Exception.Message)" throw } return @{ sequence = $sequence recordSha256 = $stored.RecordSha256 } } function Test-PipeNameInUse { <# The pipe namespace is enumerated rather than probed with Test-Path: opening the path counts as a client connection and leaves the listening server's pending wait faulted, so a existence check would disturb the very service it asks about. #> [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory)] [string]$Name ) foreach ($entry in [System.IO.Directory]::GetFiles('\\.\pipe\')) { if (Test-OrdinalEqual -Left ([System.IO.Path]::GetFileName($entry)) -Right $Name) { return $true } } return $false } function New-RestrictedPipeServer { <# The pipe is the authentication boundary. Only the appender's own account and the allowed identity get an Allow ACE, a network logon is denied outright, and the owner is left to the creator: an owner can rewrite the DACL, so handing ownership to the caller would hand it the pipe. Only the appender's own account is granted CreateNewInstance, which is what lets the service keep a listening instance while it works and what stops another identity from adding one of its own. Failing to apply the ACL is fatal rather than degraded, because a default DACL would silently widen who may append evidence. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Internal helper of a service mode that is already an explicit, non-interactive decision.')] [CmdletBinding()] [OutputType([System.IO.Pipes.NamedPipeServerStream])] param( [Parameter(Mandatory)] [string]$Name, [Parameter(Mandatory)] [AllowEmptyString()] [string]$Identity ) $security = [System.IO.Pipes.PipeSecurity]::new() $current = [System.Security.Principal.WindowsIdentity]::GetCurrent() $security.AddAccessRule([System.IO.Pipes.PipeAccessRule]::new( $current.User, [System.IO.Pipes.PipeAccessRights]::ReadWrite -bor [System.IO.Pipes.PipeAccessRights]::CreateNewInstance, [System.Security.AccessControl.AccessControlType]::Allow)) if (-not [string]::IsNullOrWhiteSpace($Identity)) { $account = [System.Security.Principal.NTAccount]::new($Identity) $allowed = $account.Translate([System.Security.Principal.SecurityIdentifier]) if (-not (Test-OrdinalEqual -Left $allowed.Value -Right $current.User.Value)) { $security.AddAccessRule([System.IO.Pipes.PipeAccessRule]::new( $allowed, [System.IO.Pipes.PipeAccessRights]::ReadWrite, [System.Security.AccessControl.AccessControlType]::Allow)) } } $security.AddAccessRule([System.IO.Pipes.PipeAccessRule]::new( [System.Security.Principal.SecurityIdentifier]::new('S-1-5-2'), [System.IO.Pipes.PipeAccessRights]::FullControl, [System.Security.AccessControl.AccessControlType]::Deny)) # Two instances, so the next one can be created while this one is still # serving a client and the name is never unowned for an instant. return [System.IO.Pipes.NamedPipeServerStreamAcl]::Create( $Name, [System.IO.Pipes.PipeDirection]::InOut, 2, [System.IO.Pipes.PipeTransmissionMode]::Byte, [System.IO.Pipes.PipeOptions]::Asynchronous, 0, 0, $security) } function Get-ExpectedServerIdentity { <# Which principal the client requires the pipe to belong to. By default that is whoever would own an object this client created, which is the same comparison PipeOptions.CurrentUserOnly makes inside .NET; an explicit identity replaces it when the appender runs as another account. #> [CmdletBinding()] [OutputType([hashtable])] param( [Parameter(Mandatory)] [AllowEmptyString()] [string]$Identity ) $current = [System.Security.Principal.WindowsIdentity]::GetCurrent() $self = [System.Collections.Generic.HashSet[string]]::new( [string[]]@($current.User.Value, $current.Owner.Value), [System.StringComparer]::Ordinal) if ([string]::IsNullOrWhiteSpace($Identity)) { return @{ Owner = $self; Allowed = $self; Explicit = $false } } if ($Identity -cmatch $script:SidPattern) { $sid = [System.Security.Principal.SecurityIdentifier]::new($Identity) } else { $sid = ([System.Security.Principal.NTAccount]::new($Identity)).Translate([System.Security.Principal.SecurityIdentifier]) } # The owner must be exactly the identity the caller named; the channel may # additionally be open to this client, and to nobody else. $owner = [System.Collections.Generic.HashSet[string]]::new([string[]]@($sid.Value), [System.StringComparer]::Ordinal) $allowed = [System.Collections.Generic.HashSet[string]]::new($self, [System.StringComparer]::Ordinal) $null = $allowed.Add($sid.Value) return @{ Owner = $owner Allowed = $allowed Explicit = $true } } function Test-PipeServerIdentity { <# What this proves: the pipe object belongs to the expected principal and grants access to nobody else. Setting another account as owner needs a privilege an ordinary local identity does not have, so a squatter that is not the appender identity cannot present this security descriptor. What it does not prove: that the process behind the pipe is the pinned appender rather than another process of the same identity. That is why the acknowledgement is cross-checked against the ledger afterwards. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [System.IO.Pipes.NamedPipeClientStream]$Client, [Parameter(Mandatory)] [hashtable]$Expected ) try { $security = [System.IO.Pipes.PipesAclExtensions]::GetAccessControl($Client) } catch { return "the pipe's security descriptor cannot be read: $($_.Exception.Message)" } $owner = $security.GetOwner([System.Security.Principal.SecurityIdentifier]) if ($null -eq $owner -or -not $Expected.Owner.Contains($owner.Value)) { return "the pipe is owned by $($owner) and not by the expected appender identity" } foreach ($rule in $security.GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier])) { if ($rule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow) { continue } if (-not $Expected.Allowed.Contains($rule.IdentityReference.Value)) { return "the pipe grants access to $($rule.IdentityReference.Value), so the channel is not exclusive" } } return '' } function Invoke-AppenderRequest { <# Identification, never Impersonation: a server on this pipe must not be able to act as the caller. The server is authenticated before the record is handed over, because a record given to a squatter is already disclosed whatever the answer says. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [string]$Name, [Parameter(Mandatory)] [string]$Payload, [Parameter(Mandatory)] [hashtable]$Expected ) # PipeOptions.CurrentUserOnly is deliberately not used: .NET compares the # pipe owner with this token's owner only after the handle is open, and the # check below makes the same comparison plus the DACL, before a single byte # of the record is written. $client = [System.IO.Pipes.NamedPipeClientStream]::new( '.', $Name, [System.IO.Pipes.PipeDirection]::InOut, [System.IO.Pipes.PipeOptions]::None, [System.Security.Principal.TokenImpersonationLevel]::Identification) try { $client.Connect(10000) $identityError = Test-PipeServerIdentity -Client $client -Expected $Expected if ($identityError) { throw $identityError } $writer = [System.IO.StreamWriter]::new($client, $script:Utf8, 4096, $true) $writer.AutoFlush = $true $writer.WriteLine($Payload) $writer.Dispose() $reader = [System.IO.StreamReader]::new($client, $script:Utf8, $false, 4096, $true) try { return $reader.ReadLine() } finally { $reader.Dispose() } } finally { $client.Dispose() } } function Read-AppenderResponse { <# The response comes from the other side of the pipe, so it is parsed as strictly as stored evidence: a fixed shape, a status the client knows, a sequence that is a positive integer and a lowercase digest. #> [CmdletBinding()] [OutputType([hashtable])] param( [Parameter(Mandatory)] [System.Text.Json.JsonElement]$Element ) $response = @{ Error = ''; Status = ''; Message = ''; Sequence = 0; RecordSha256 = '' } if ($Element.ValueKind -ne [System.Text.Json.JsonValueKind]::Object) { $response.Error = 'appender returned a response that is not a JSON object' return $response } try { $member = Get-CanonicalMember -Element $Element } catch { $response.Error = "appender returned an unreadable response: $($_.Exception.Message)" return $response } foreach ($required in @('status', 'message', 'sequence', 'recordSha256')) { if (-not $member.Contains($required)) { $response.Error = "appender returned a response without '$required'" return $response } } $status = $Element.GetProperty('status') $message = $Element.GetProperty('message') if ($status.ValueKind -ne [System.Text.Json.JsonValueKind]::String -or $message.ValueKind -ne [System.Text.Json.JsonValueKind]::String) { $response.Error = 'appender returned a response whose status or message is not a string' return $response } $response.Status = $status.GetString() $response.Message = $message.GetString() if (-not (Test-OrdinalEqual -Left $response.Status -Right 'Ok')) { return $response } if ([string]$member['sequence'] -cnotmatch $script:IntegerPattern -or [long]$member['sequence'] -lt 1) { $response.Error = 'appender acknowledged a sequence that is not a positive integer' return $response } $digest = $Element.GetProperty('recordSha256') if ($digest.ValueKind -ne [System.Text.Json.JsonValueKind]::String -or $digest.GetString() -cnotmatch $script:DigestPattern) { $response.Error = 'appender acknowledged a digest that is not a lowercase 64-character hexadecimal string' return $response } $response.Sequence = [long]$member['sequence'] $response.RecordSha256 = $digest.GetString() return $response } function Test-Acknowledgement { <# The acknowledgement is a claim by whoever answered the pipe, and the ledger alone cannot settle it: a server that answers a new record with the sequence and digest of a record that is already stored says only true things about the ledger while storing nothing. So the client recomputes the digest of the record it sent, at the acknowledged sequence and on top of the record before it, and requires that digest to be both what was acknowledged and what is stored. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [int]$Sequence, [Parameter(Mandatory)] [string]$RecordSha256, [Parameter(Mandatory)] [System.Text.Json.JsonElement]$Record, [Parameter(Mandatory)] [string]$Path, [Parameter(Mandatory)] [string]$HeadPath ) if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return "the acknowledged record is not in the ledger: '$Path' does not exist" } $state = Read-ChainState -Path $Path -HeadPath $HeadPath if ($state.Error) { return "the acknowledged record is not in a valid ledger: $($state.Error)" } if ($state.Sequence -lt $Sequence) { return "the ledger ends at sequence $($state.Sequence) but the acknowledgement names $Sequence" } $sink = Read-SinkLine -Path $Path if ($sink.Error) { return "the acknowledged record cannot be read back: $($sink.Error)" } # Not $record: PowerShell variable names are case-insensitive, so that name # would overwrite the record this function was given. $stored = Read-StoredRecord -Text $sink.Line[$Sequence - 1] if ($stored.Error) { return "the acknowledged record cannot be read back: it $($stored.Error)" } $previous = $script:GenesisHash if ($Sequence -gt 1) { $earlier = Read-StoredRecord -Text $sink.Line[$Sequence - 2] if ($earlier.Error) { return "the record before the acknowledged one cannot be read back: it $($earlier.Error)" } $previous = $earlier.RecordSha256 } try { $expected = New-StoredRecord -Record $Record -Sequence $Sequence -PreviousHash $previous } catch { return "the record that was sent cannot be rebuilt: $($_.Exception.Message)" } if (-not (Test-OrdinalEqual -Left $expected.RecordSha256 -Right $stored.RecordSha256)) { return "the ledger does not hold the record that was sent at sequence $Sequence" } if (-not (Test-OrdinalEqual -Left $expected.RecordSha256 -Right $RecordSha256)) { return "the acknowledgement names a digest that is not the record that was sent" } if (-not (Test-OrdinalEqual -Left $stored.RecordSha256 -Right $RecordSha256)) { return "the ledger holds a different record at sequence $Sequence than the acknowledgement names" } $head = Read-ChainHeadState -Path $HeadPath if ($head.Error) { return "the acknowledged record is not published: $($head.Error)" } if ($head.Sequence -lt $Sequence) { return "the chain head is behind the acknowledged sequence $Sequence" } return '' } switch ($PSCmdlet.ParameterSetName) { 'Serve' { if (-not $script:OnWindows) { # A Unix named pipe is a filesystem object with no owner check the # client can make, so neither side could authenticate the other. # Refusing is the only honest answer; nothing here measures it. Write-Refusal -Message 'the appender service is only supported on Windows, where the pipe can be restricted by ACL' exit 2 } # Both directories, not only the sink's: a head that cannot be written # is fatal, so an absent directory would stop the service on its first # append rather than at start. foreach ($directory in @((Split-Path -Parent $SinkPath), (Split-Path -Parent $ChainHeadPath))) { if ($directory -and -not (Test-Path -LiteralPath $directory)) { $null = New-Item -Path $directory -ItemType Directory -Force } } $state = Read-ChainState -Path $SinkPath -HeadPath $ChainHeadPath if ($state.Error) { Write-Refusal -Message "existing ledger is not valid: $($state.Error)" exit 2 } $head = Read-ChainHeadState -Path $ChainHeadPath if ($head.Error) { Write-Refusal -Message $head.Error exit 2 } $agreement = Test-HeadAgreement -State $state -Head $head if ($agreement.Error) { Write-Refusal -Message "the ledger sink and the chain head disagree: $($agreement.Error)" exit 2 } if ($agreement.CatchUp) { # The only interrupted step that can be completed: the record is # already durable, so the head is finished, never the sink. [Console]::Error.WriteLine( "Ledger appender completed an interrupted head write up to sequence $($state.Sequence).") Write-ChainHead -Path $ChainHeadPath -Sequence $state.Sequence ` -RecordHash $state.LastHash -Sealed $state.Sealed } if (Test-PipeNameInUse -Name $PipeName) { Write-Refusal -Message "the pipe name '$PipeName' is already in use; another appender or an impostor holds it" exit 2 } # The sink is held open for the whole run. It is the second-instance # control: a second appender on the same ledger is refused here rather # than racing the first one, and no other process can open it for write # while the service is alive. try { $sinkStream = [System.IO.FileStream]::new($SinkPath, [System.IO.FileMode]::Append, [System.IO.FileAccess]::Write, [System.IO.FileShare]::Read) } catch { Write-Refusal -Message "could not take the ledger sink '$SinkPath': $($_.Exception.Message)" exit 2 } $listening = $null try { try { $listening = New-RestrictedPipeServer -Name $PipeName -Identity $AllowedIdentity } catch { Write-Refusal -Message "could not create a restricted pipe: $($_.Exception.Message)" exit 2 } $idleDeadline = (Get-Date).AddSeconds($IdleTimeoutSeconds) while ((Get-Date) -lt $idleDeadline) { $remaining = $idleDeadline - (Get-Date) if ($remaining -le [timespan]::Zero) { break } $connected = $false try { $connected = $listening.WaitForConnectionAsync().Wait($remaining) } catch { # Anything that touches the pipe counts as a connection, # including a bare existence probe, and leaves the instance # faulted. The replacement is created before the faulted one # is released, so the name stays owned throughout. [Console]::Error.WriteLine( "Ledger appender dropped a connection attempt: $($_.Exception.GetBaseException().Message)") $faulted = $listening $listening = $null try { $listening = New-RestrictedPipeServer -Name $PipeName -Identity $AllowedIdentity } catch { Write-Refusal -Message "could not keep a listening instance of '$PipeName': $($_.Exception.Message)" $faulted.Dispose() exit 2 } $faulted.Dispose() continue } if (-not $connected) { break } # One stream per connection: a broken pipe leaves the stream # faulted for good. The next instance is created before this one # is serviced, so the name is never unowned and nobody can take # it in the gap. $server = $listening $listening = $null try { try { $listening = New-RestrictedPipeServer -Name $PipeName -Identity $AllowedIdentity } catch { Write-Refusal -Message "could not keep a listening instance of '$PipeName': $($_.Exception.Message)" $server.Dispose() exit 2 } $reader = [System.IO.StreamReader]::new($server, $script:Utf8, $false, 4096, $true) $writer = [System.IO.StreamWriter]::new($server, $script:Utf8, 4096, $true) $writer.AutoFlush = $true $requestText = Read-RequestLine -Reader $reader $response = [ordered]@{ status = 'Error'; message = 'empty request'; sequence = 0; recordSha256 = '' } if (-not [string]::IsNullOrWhiteSpace($requestText)) { $document = $null try { $document = ConvertFrom-JsonText -Text $requestText } catch { $response.message = 'request is not valid JSON' } if ($null -ne $document) { try { $request = Read-AppenderRequest -Element $document.RootElement if ($request.Error) { $response.message = $request.Error } elseif ($state.Sealed) { $response.message = 'ledger is sealed' } else { $appended = Add-Record -Record $request.Record -Operation $request.Operation ` -State $state -Stream $sinkStream -HeadPath $ChainHeadPath $response = [ordered]@{ status = 'Ok' message = '' sequence = $appended.sequence recordSha256 = $appended.recordSha256 } } } finally { $document.Dispose() } } } $writer.WriteLine(($response | ConvertTo-Json -Compress)) $reader.Dispose() $writer.Dispose() # Disposing the stream discards anything the client has not # read yet, so the acknowledgement must be drained before # teardown. $server.WaitForPipeDrain() # Only a served connection resets the idle window, so a # client that merely probes the pipe cannot keep the service # alive. $idleDeadline = (Get-Date).AddSeconds($IdleTimeoutSeconds) } catch { if ($script:FatalError) { # Never continue on stale state: the record may be # durable but unpublished, and the next append would # have to guess which sequence is free. Write-Refusal -Message $script:FatalError $server.Dispose() exit 2 } # A client that hangs up, times out, or sends garbage must # not take the ledger service down with it: losing the # appender mid-run would strand every later record. [Console]::Error.WriteLine("Ledger appender dropped a connection: $($_.Exception.Message)") } finally { $server.Dispose() } } } finally { if ($null -ne $listening) { $listening.Dispose() } $sinkStream.Dispose() } exit 0 } { $_ -in @('Append', 'Seal') } { if (-not $script:OnWindows) { # The client cannot authenticate a Unix socket's server, and # handing a record to an unauthenticated server is the failure # C-04 describes. Refusing is the only safe answer. Write-Refusal -Message 'the appender client is only supported on Windows, where the pipe server can be authenticated' exit 2 } # Parsed once, and kept until the acknowledgement has been checked: the # client has to rebuild the digest of this exact record to know whether # the ledger holds what it sent. try { $sent = ConvertFrom-JsonText -Text $RecordJson } catch { Write-Refusal -Message 'record is not valid JSON' exit 2 } try { if ($Seal) { # -Seal must not be a quiet alias for -Append: sealing is # irreversible, so the intent has to be visible in the record too. $intendedType = '' if ($sent.RootElement.ValueKind -eq [System.Text.Json.JsonValueKind]::Object) { $typeElement = [System.Text.Json.JsonElement]::new() if ($sent.RootElement.TryGetProperty('type', [ref]$typeElement) -and $typeElement.ValueKind -eq [System.Text.Json.JsonValueKind]::String) { $intendedType = $typeElement.GetString() } } if (-not (Test-OrdinalEqual -Left $intendedType -Right 'Seal')) { Write-Refusal -Message "-Seal requires a record of type 'Seal'" exit 2 } } try { $expected = Get-ExpectedServerIdentity -Identity $ExpectedServerIdentity } catch { Write-Refusal -Message "could not resolve the expected appender identity: $($_.Exception.Message)" exit 2 } # The record is forwarded byte for byte inside an envelope that states # the operation. Re-serializing it here would change the bytes the # server hashes; a record crafted to close the envelope early produces # a repeated field, which the server refuses. $operation = if ($Seal) { 'Seal' } else { 'Append' } $payload = '{"operation":"' + $operation + '","record":' + $RecordJson + '}' try { $responseText = Invoke-AppenderRequest -Name $PipeName -Payload $payload -Expected $expected } catch { Write-Refusal -Message "could not reach a trusted appender on pipe '$PipeName': $($_.Exception.Message)" exit 2 } if ([string]::IsNullOrWhiteSpace($responseText)) { Write-Refusal -Message 'appender returned no response' exit 2 } try { $document = ConvertFrom-JsonText -Text $responseText } catch { Write-Refusal -Message 'appender returned a response that is not valid JSON' exit 2 } try { $response = Read-AppenderResponse -Element $document.RootElement } finally { $document.Dispose() } if ($response.Error) { Write-Refusal -Message $response.Error exit 2 } if (-not (Test-OrdinalEqual -Left $response.Status -Right 'Ok')) { Write-Refusal -Message $response.Message exit 2 } # The answer came from the pipe, so it is a claim, not evidence. Only # the ledger itself can show that the record the client sent was # stored and published. $acknowledgementError = Test-Acknowledgement -Sequence $response.Sequence ` -RecordSha256 $response.RecordSha256 -Record $sent.RootElement ` -Path $SinkPath -HeadPath $ChainHeadPath if ($acknowledgementError) { Write-Refusal -Message "the acknowledgement is not backed by the ledger: $acknowledgementError" exit 2 } Write-Output ('Appended sequence {0} as {1}' -f $response.Sequence, $response.RecordSha256) exit 0 } finally { $sent.Dispose() } } { $_ -in @('Validate', 'ValidateBound') } { $bound = Test-OrdinalEqual -Left $PSCmdlet.ParameterSetName -Right 'ValidateBound' $resolvedSink = $SinkPath $resolvedHead = $ChainHeadPath if ($bound) { $containmentProfile = Read-ContainmentProfile -Path $ProfilePath -Sha256 $ProfileSha256 if ($containmentProfile.Error) { Write-Refusal -Message $containmentProfile.Error exit 2 } $pinnedError = Test-PinnedAppender -Path $containmentProfile.AppenderPath -Sha256 $containmentProfile.AppenderSha256 if ($pinnedError) { Write-Refusal -Message $pinnedError exit 2 } $resolvedSink = $containmentProfile.SinkPath $resolvedHead = $containmentProfile.ChainHeadPath } else { [Console]::Error.WriteLine( 'Ledger appender warning: an unbound check proves only that the given files form a valid chain, not that they are the ledger a run was contained to.') } if (-not (Test-Path -LiteralPath $resolvedSink -PathType Leaf)) { Write-Refusal -Message "ledger sink '$resolvedSink' does not exist; an absent chain is never valid" exit 2 } if (-not (Test-Path -LiteralPath $resolvedHead -PathType Leaf)) { Write-Refusal -Message "chain head '$resolvedHead' does not exist" exit 2 } $state = Read-ChainState -Path $resolvedSink -HeadPath $resolvedHead if ($state.Error) { Write-Refusal -Message $state.Error exit 2 } if ($state.Sequence -eq 0) { Write-Refusal -Message 'ledger contains no records' exit 2 } $head = Read-ChainHeadState -Path $resolvedHead if ($head.Error) { Write-Refusal -Message $head.Error exit 2 } $agreement = Test-HeadAgreement -State $state -Head $head if ($agreement.Error) { Write-Refusal -Message $agreement.Error exit 2 } if ($agreement.CatchUp) { # Validation proves a finished ledger. A head that still has to # catch up is a chain the service has not finished writing. Write-Refusal -Message ("the chain head names sequence {0} but the ledger ends at {1}" -f $head.Sequence, $state.Sequence) exit 2 } if ($RequireSealed -and -not ($state.Sealed -and $head.Sealed)) { Write-Refusal -Message 'the ledger is not sealed: the last record is not a seal record or the chain head does not say so' exit 2 } # Exactly one JSON object, and only when everything above held. Write-Output ('{{"valid":true,"records":{0},"headSequence":{1},"sealed":{2},"headRecordSha256":"{3}","bound":{4}}}' -f $state.Sequence, $head.Sequence, $(if ($state.Sealed) { 'true' } else { 'false' }), $state.LastHash, $(if ($bound) { 'true' } else { 'false' })) exit 0 } } |