@rightkit/release 0.2.151 → 0.2.154
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE-APACHE +201 -0
- package/LICENSE-MIT +21 -0
- package/README.md +117 -8
- package/build-release.mjs +7 -2
- package/cli/right-release.mjs +18 -5
- package/committed-source.mjs +19 -1
- package/crate-graph.mjs +121 -0
- package/deps.mjs +0 -0
- package/development-signing.mjs +8 -0
- package/development.mjs +46 -13
- package/devtools-policy.json +5 -0
- package/devtools-policy.mjs +119 -0
- package/direct-bootstrap.mjs +9 -3
- package/dmg-build.mjs +19 -5
- package/dmg-layout.mjs +2 -2
- package/dmg-spec.mjs +97 -0
- package/execution-policy.mjs +17 -1
- package/github-release.mjs +25 -7
- package/github-upload-gate.mjs +84 -0
- package/hardeningscan.mjs +3 -0
- package/inventory-check.mjs +4 -4
- package/lsclean.sh +0 -0
- package/mac-bundle-signing.mjs +1 -1
- package/mac-sign-core.mjs +97 -0
- package/mac-signing-matrix.mjs +186 -0
- package/msix-appinstaller.mjs +4 -1
- package/msix-build.mjs +3 -6
- package/msix-config.mjs +5 -1
- package/msix-repair-guard.mjs +24 -0
- package/msix-repair-guard.ps1 +356 -0
- package/msix-signer.mjs +211 -0
- package/ownership-catalog-config.json +6 -7
- package/ownership-command.mjs +69 -8
- package/ownership-evidence.mjs +1 -1
- package/ownership-key-authority.mjs +1 -1
- package/ownership-key-command.mjs +15 -3
- package/ownership-key-elevation.mjs +11 -2
- package/ownership-mandatory-set.json +420 -0
- package/ownership-mandatory-set.mjs +273 -0
- package/ownership.mjs +2 -2
- package/package.json +25 -11
- package/packaged-app-identity.mjs +88 -0
- package/preflight.mjs +25 -3
- package/prune-r2.mjs +28 -3
- package/publication-gate.mjs +49 -10
- package/publish-cargo.mjs +21 -1
- package/publish-swift.mjs +78 -141
- package/publish-update.mjs +24 -2
- package/qa-build-features.mjs +216 -0
- package/qa-control-scan.mjs +435 -0
- package/registry-parity.mjs +18 -1
- package/release-invocation.mjs +2 -1
- package/release-state.mjs +9 -0
- package/release-switch-registry.json +33 -0
- package/release-switch-scan.mjs +452 -0
- package/release.mjs +320 -26
- package/renamed-away.mjs +29 -0
- package/rightkit-ownership.json +682 -709
- package/rightkit-sync.mjs +8 -4
- package/rightkit-version-policy.mjs +18 -0
- package/rightkit-versions.json +211 -127
- package/runtime-budget-classes.json +4 -0
- package/runtime-budgets.mjs +870 -0
- package/runtime-bundle-scan.mjs +61 -0
- package/sign-macos.mjs +28 -30
- package/sign-updater.mjs +5 -2
- package/swift-mac-dmg.mjs +7 -2
- package/swift-package-command.mjs +1 -1
- package/upload-large.mjs +0 -0
- package/upload-release.mjs +10 -37
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
# RightKit MSIX repair guard (canonical source: rightkit/packages/release/msix-repair-guard.ps1). ASCII only; Windows PowerShell 5.1 compatible.
|
|
2
|
+
#
|
|
3
|
+
# Why this exists: cancelling an MSIX update while Windows registers it can leave the OLD package
|
|
4
|
+
# "Modified, NeedsRemediation" (event 751: "Windows failed to undo changes ... The package needs to be reinstalled").
|
|
5
|
+
# A package in that state cannot be launched, so nothing inside the package can repair it. This script lives OUTSIDE the
|
|
6
|
+
# package (the app writes it to %LOCALAPPDATA%\<AppName>\repair\ on every normal launch) and is run by a per-user scheduled
|
|
7
|
+
# task `<PFN>-Repair` (no admin) on event 751 in Microsoft-Windows-AppXDeploymentServer/Operational, at logon, and at a
|
|
8
|
+
# one-shot retry time after a failed repair.
|
|
9
|
+
#
|
|
10
|
+
# msix-repair-guard.ps1 -Install -Pfn <family> [-Feed <.appinstaller uri>] register/refresh the task and write guard.json
|
|
11
|
+
# msix-repair-guard.ps1 (task action) repair if needed, clean up if uninstalled
|
|
12
|
+
#
|
|
13
|
+
# Repair order: `Add-AppxPackage -AppInstallerFile <feed>` (known-good), then `Add-AppxPackage -Path <msix>` (the msix is downloaded
|
|
14
|
+
# first and its manifest identity checked). Every decision is logged to repair.log next to this script; every run also writes a
|
|
15
|
+
# machine-readable last-result.json (status, code, time, detail), because the task runs under `conhost --headless` and hides exit codes.
|
|
16
|
+
#
|
|
17
|
+
# Exit codes (also in last-result.json): 0 healthy / repaired / cleaned up / another run active, 1 repair failed (a retry is scheduled),
|
|
18
|
+
# 2 guard misconfigured or refused (guard.json or an install input failed validation), 3 no install source known, 4 unhandled error
|
|
19
|
+
# (logged; a retry is scheduled).
|
|
20
|
+
#
|
|
21
|
+
# Retry: after a failed repair (exit 1 or 4) the script gives the task a one-shot time trigger at +2, +5, +15, +60 minutes and then
|
|
22
|
+
# hourly, for at most 24 hours from the first failure; the trigger is removed on success, when the package is healthy, or when the
|
|
23
|
+
# app is uninstalled. State: retry.json. A later logon or event 751 run still starts a repair at any time.
|
|
24
|
+
#
|
|
25
|
+
# Uninstall cleanup: a run that finds the package absent (twice, 10 s apart) removes this folder and the task. It runs at the next
|
|
26
|
+
# logon, the next event 751, or the next retry tick; Windows has no "package removed" trigger for a per-user task.
|
|
27
|
+
#
|
|
28
|
+
# Hardening: feed and msix URIs must be https, or http to a loopback host (a local test feed). A feed or msix whose identity (Name,
|
|
29
|
+
# Publisher) differs from the installed package is refused and never installed. guard.json must name this folder's task.
|
|
30
|
+
#
|
|
31
|
+
# Requirement: the app's data directory must be physical (MSIX fileSystemWrite virtualization disabled), otherwise the packaged app
|
|
32
|
+
# writes this folder into its private LocalCache and the task cannot see it.
|
|
33
|
+
[CmdletBinding()]
|
|
34
|
+
param([switch]$Install, [string]$Pfn = '', [string]$Feed = '')
|
|
35
|
+
$ErrorActionPreference = 'Stop'
|
|
36
|
+
$ProgressPreference = 'SilentlyContinue' # Windows PowerShell 5.1 renders a progress bar per downloaded chunk: a 30 MB Invoke-WebRequest -OutFile then takes minutes
|
|
37
|
+
$GuardVersion = 2
|
|
38
|
+
$Dir = Split-Path -Parent $PSCommandPath
|
|
39
|
+
$LogPath = Join-Path $Dir 'repair.log'
|
|
40
|
+
$ConfigPath = Join-Path $Dir 'guard.json'
|
|
41
|
+
$RetryPath = Join-Path $Dir 'retry.json'
|
|
42
|
+
$AttemptPath = Join-Path $Dir 'install-attempt.json'
|
|
43
|
+
$ResultPath = Join-Path $Dir $(if ($Install) { 'install-result.json' } else { 'last-result.json' })
|
|
44
|
+
$Mode = $(if ($Install) { 'install' } else { 'repair' })
|
|
45
|
+
$RetryDelaysMinutes = @(2, 5, 15, 60) # then hourly
|
|
46
|
+
$RetryWindowHours = 24
|
|
47
|
+
$PfnPattern = '^[A-Za-z0-9][A-Za-z0-9.\-]*_[a-z0-9]{13}$'
|
|
48
|
+
$script:Ctx = ''
|
|
49
|
+
|
|
50
|
+
function Log([string]$m) {
|
|
51
|
+
try {
|
|
52
|
+
if ((Test-Path -LiteralPath $LogPath) -and ((Get-Item -LiteralPath $LogPath).Length -gt 262144)) { Move-Item -LiteralPath $LogPath -Destination ($LogPath + '.old') -Force }
|
|
53
|
+
Add-Content -LiteralPath $LogPath -Value ((Get-Date).ToString('yyyy-MM-ddTHH:mm:ss.fffzzz') + ' [' + $Mode + '] ' + $m)
|
|
54
|
+
} catch { }
|
|
55
|
+
}
|
|
56
|
+
function Write-Json([string]$path, $obj) {
|
|
57
|
+
try {
|
|
58
|
+
$tmp = $path + '.tmp'
|
|
59
|
+
[IO.File]::WriteAllText($tmp, ($obj | ConvertTo-Json), (New-Object Text.UTF8Encoding($false)))
|
|
60
|
+
Move-Item -LiteralPath $tmp -Destination $path -Force
|
|
61
|
+
} catch { Log ('could not write ' + $path + ': ' + $_.Exception.Message) }
|
|
62
|
+
}
|
|
63
|
+
function Write-Result([string]$status, [int]$code, [string]$detail) {
|
|
64
|
+
$r = [ordered]@{ status = $status; code = $code; mode = $Mode; timeUtc = (Get-Date).ToUniversalTime().ToString('o'); guardVersion = $GuardVersion; detail = $detail }
|
|
65
|
+
$st = Read-Json $RetryPath
|
|
66
|
+
if ($st -and $st.nextEpoch) { $r.nextRetryUtc = ([datetime]'1970-01-01T00:00:00Z').ToUniversalTime().AddSeconds([double]$st.nextEpoch).ToString('o'); $r.retryAttempt = [int]$st.attempt }
|
|
67
|
+
Write-Json $ResultPath $r
|
|
68
|
+
}
|
|
69
|
+
function Finish([string]$status, [int]$code, [string]$detail) {
|
|
70
|
+
Log ($status.ToUpper() + ' (code ' + $code + '): ' + $detail)
|
|
71
|
+
Write-Result $status $code $detail
|
|
72
|
+
exit $code
|
|
73
|
+
}
|
|
74
|
+
function Read-Json([string]$path) { try { return (Get-Content -LiteralPath $path -Raw -ErrorAction Stop | ConvertFrom-Json) } catch { return $null } }
|
|
75
|
+
function Read-Config { return (Read-Json $ConfigPath) }
|
|
76
|
+
|
|
77
|
+
# ------------------------------------------------------------------ scheduled task (shared by install and the retry logic)
|
|
78
|
+
function Get-TaskXml([string]$family, $retryLocal) {
|
|
79
|
+
$id = [Security.Principal.WindowsIdentity]::GetCurrent()
|
|
80
|
+
$esc = { param($s) [Security.SecurityElement]::Escape([string]$s) }
|
|
81
|
+
$sub = '<QueryList><Query Id="0" Path="Microsoft-Windows-AppXDeploymentServer/Operational"><Select Path="Microsoft-Windows-AppXDeploymentServer/Operational">*[System[(EventID=751)]]</Select></Query></QueryList>'
|
|
82
|
+
$taskArgs = '--headless powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "' + $PSCommandPath + '"'
|
|
83
|
+
$retryXml = ''
|
|
84
|
+
if ($retryLocal) { $retryXml = '<TimeTrigger><StartBoundary>' + ([datetime]$retryLocal).ToString('yyyy-MM-ddTHH:mm:ss') + '</StartBoundary><Enabled>true</Enabled></TimeTrigger>' }
|
|
85
|
+
return @"
|
|
86
|
+
<?xml version="1.0" encoding="UTF-16"?>
|
|
87
|
+
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
88
|
+
<RegistrationInfo><Description>Repair guard for $(& $esc $family): re-adds the app from its App Installer feed when an interrupted update leaves it NeedsRemediation; retries after a failure. Removes itself when the app is uninstalled.</Description></RegistrationInfo>
|
|
89
|
+
<Triggers>
|
|
90
|
+
<EventTrigger><Enabled>true</Enabled><Subscription>$(& $esc $sub)</Subscription></EventTrigger>
|
|
91
|
+
<LogonTrigger><Enabled>true</Enabled><UserId>$(& $esc $id.Name)</UserId></LogonTrigger>
|
|
92
|
+
$retryXml
|
|
93
|
+
</Triggers>
|
|
94
|
+
<Principals><Principal id="Author"><UserId>$(& $esc $id.User.Value)</UserId><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>
|
|
95
|
+
<Settings>
|
|
96
|
+
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
97
|
+
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
98
|
+
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
99
|
+
<AllowHardTerminate>true</AllowHardTerminate>
|
|
100
|
+
<StartWhenAvailable>true</StartWhenAvailable>
|
|
101
|
+
<AllowStartOnDemand>true</AllowStartOnDemand>
|
|
102
|
+
<Enabled>true</Enabled>
|
|
103
|
+
<Hidden>true</Hidden>
|
|
104
|
+
<ExecutionTimeLimit>PT30M</ExecutionTimeLimit>
|
|
105
|
+
</Settings>
|
|
106
|
+
<Actions Context="Author"><Exec><Command>%SystemRoot%\System32\conhost.exe</Command><Arguments>$(& $esc $taskArgs)</Arguments></Exec></Actions>
|
|
107
|
+
</Task>
|
|
108
|
+
"@
|
|
109
|
+
}
|
|
110
|
+
function Register-GuardTask([string]$family, $retryLocal) {
|
|
111
|
+
[void](Register-ScheduledTask -TaskName ($family + '-Repair') -Xml (Get-TaskXml $family $retryLocal) -Force)
|
|
112
|
+
}
|
|
113
|
+
function Get-RetryLocal {
|
|
114
|
+
$st = Read-Json $RetryPath
|
|
115
|
+
if ($st -and $st.nextEpoch) { return ([datetime]'1970-01-01T00:00:00Z').ToUniversalTime().AddSeconds([double]$st.nextEpoch).ToLocalTime() }
|
|
116
|
+
return $null
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
# ------------------------------------------------------------------ retry state (repair mode)
|
|
120
|
+
function Clear-Retry([string]$family) {
|
|
121
|
+
if (-not (Test-Path -LiteralPath $RetryPath)) { return }
|
|
122
|
+
try { Remove-Item -LiteralPath $RetryPath -Force } catch { }
|
|
123
|
+
try { Register-GuardTask $family $null; Log 'retry cleared (task has no one-shot trigger)' } catch { Log ('could not clear the retry trigger: ' + $_.Exception.Message) }
|
|
124
|
+
}
|
|
125
|
+
function Set-Retry([string]$family, [string]$why) {
|
|
126
|
+
$epoch0 = [datetime]'1970-01-01T00:00:00Z'
|
|
127
|
+
$now = [datetime]::UtcNow
|
|
128
|
+
$nowEpoch = [math]::Floor(($now - $epoch0.ToUniversalTime()).TotalSeconds)
|
|
129
|
+
$st = Read-Json $RetryPath
|
|
130
|
+
$attempt = 1; $first = $nowEpoch
|
|
131
|
+
if ($st -and $st.firstFailureEpoch -and (($nowEpoch - [double]$st.firstFailureEpoch) -lt ($RetryWindowHours * 3600))) { $attempt = [int]$st.attempt + 1; $first = [double]$st.firstFailureEpoch }
|
|
132
|
+
$delay = $(if ($attempt -le $RetryDelaysMinutes.Count) { $RetryDelaysMinutes[$attempt - 1] } else { 60 })
|
|
133
|
+
$next = $nowEpoch + ($delay * 60)
|
|
134
|
+
if (($next - $first) -gt ($RetryWindowHours * 3600)) {
|
|
135
|
+
Write-Json $RetryPath ([ordered]@{ attempt = $attempt; firstFailureEpoch = $first; gaveUp = $true; reason = $why })
|
|
136
|
+
try { Register-GuardTask $family $null } catch { }
|
|
137
|
+
Log ('retry window of ' + $RetryWindowHours + ' h used up after ' + ($attempt - 1) + ' retries; no further automatic retry (next logon / event 751 starts again)')
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
Write-Json $RetryPath ([ordered]@{ attempt = $attempt; firstFailureEpoch = $first; nextEpoch = $next; delayMinutes = $delay; reason = $why })
|
|
141
|
+
$local = $epoch0.ToUniversalTime().AddSeconds($next).ToLocalTime()
|
|
142
|
+
try { Register-GuardTask $family $local; Log ('retry ' + $attempt + ' scheduled in ' + $delay + ' min at ' + $local.ToString('s')) } catch { Log ('could not schedule the retry: ' + $_.Exception.Message) }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
# ------------------------------------------------------------------ package state
|
|
146
|
+
function Get-Family([string]$family) {
|
|
147
|
+
$name = $family.Substring(0, $family.LastIndexOf('_'))
|
|
148
|
+
return @(Get-AppxPackage -Name $name -PackageTypeFilter Main -ErrorAction SilentlyContinue | Where-Object { [string]$_.PackageFamilyName -eq $family })
|
|
149
|
+
}
|
|
150
|
+
function Get-StateText([string]$family) {
|
|
151
|
+
$p = @(Get-Family $family)
|
|
152
|
+
if ($p.Count -eq 0) { return 'absent' }
|
|
153
|
+
return (($p | ForEach-Object { [string]$_.Version + ':' + [string]$_.Status }) -join ',')
|
|
154
|
+
}
|
|
155
|
+
function Test-Healthy([string]$family) {
|
|
156
|
+
$p = @(Get-Family $family)
|
|
157
|
+
return ($p.Count -gt 0 -and @($p | Where-Object { [string]$_.Status -ne 'Ok' }).Count -eq 0)
|
|
158
|
+
}
|
|
159
|
+
function Test-AllowedUri([string]$uri) {
|
|
160
|
+
try {
|
|
161
|
+
$u = [Uri]$uri
|
|
162
|
+
if (-not $u.IsAbsoluteUri -or $u.UserInfo) { return $false }
|
|
163
|
+
if ($u.Scheme -eq 'https') { return $true }
|
|
164
|
+
if ($u.Scheme -eq 'http' -and $u.IsLoopback) { return $true }
|
|
165
|
+
} catch { }
|
|
166
|
+
return $false
|
|
167
|
+
}
|
|
168
|
+
function Same-Text([string]$a, [string]$b) { return ([string]::Equals($a.Trim(), $b.Trim(), [StringComparison]::OrdinalIgnoreCase)) }
|
|
169
|
+
function Test-Identity($expected, [string]$name, [string]$publisher) {
|
|
170
|
+
if (-not $expected.name -or -not $expected.publisher) { return 'no expected identity recorded' }
|
|
171
|
+
if (-not (Same-Text $expected.name $name)) { return ('Name ' + $name + ' differs from ' + $expected.name) }
|
|
172
|
+
if (-not (Same-Text $expected.publisher $publisher)) { return ('Publisher ' + $publisher + ' differs from ' + $expected.publisher) }
|
|
173
|
+
return ''
|
|
174
|
+
}
|
|
175
|
+
function Get-FeedInfo([string]$feedUri) {
|
|
176
|
+
$r = Invoke-WebRequest -Uri $feedUri -UseBasicParsing -TimeoutSec 20 -MaximumRedirection 3
|
|
177
|
+
if (-not (Test-AllowedUri ([string]$r.BaseResponse.ResponseUri))) { throw ('feed redirected to a disallowed uri ' + $r.BaseResponse.ResponseUri) }
|
|
178
|
+
$text = $r.Content
|
|
179
|
+
if ($text -is [byte[]]) { $text = [Text.Encoding]::UTF8.GetString($text) } # a non-text content type returns bytes
|
|
180
|
+
$x = [xml]($text.TrimStart([char]0xFEFF))
|
|
181
|
+
$n = $x.GetElementsByTagName('MainPackage') | Select-Object -First 1
|
|
182
|
+
if (-not $n) { $n = $x.GetElementsByTagName('MainBundle') | Select-Object -First 1 }
|
|
183
|
+
if (-not $n) { throw 'feed has no MainPackage or MainBundle' }
|
|
184
|
+
return [pscustomobject]@{ uri = [string]$n.Uri; name = [string]$n.Name; publisher = [string]$n.Publisher; version = [string]$n.Version }
|
|
185
|
+
}
|
|
186
|
+
function Save-Msix([string]$uri, [string]$dest) {
|
|
187
|
+
$r = Invoke-WebRequest -Uri $uri -UseBasicParsing -TimeoutSec 900 -MaximumRedirection 3 -OutFile $dest -PassThru
|
|
188
|
+
if (-not (Test-AllowedUri ([string]$r.BaseResponse.ResponseUri))) { throw ('msix redirected to a disallowed uri ' + $r.BaseResponse.ResponseUri) }
|
|
189
|
+
}
|
|
190
|
+
function Get-MsixIdentity([string]$file) {
|
|
191
|
+
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
|
192
|
+
$zip = [IO.Compression.ZipFile]::OpenRead($file)
|
|
193
|
+
try {
|
|
194
|
+
$entry = $zip.GetEntry('AppxManifest.xml')
|
|
195
|
+
$kind = 'Package'
|
|
196
|
+
if (-not $entry) { $entry = $zip.GetEntry('AppxMetadata/AppxBundleManifest.xml'); $kind = 'Bundle' }
|
|
197
|
+
if (-not $entry) { throw 'no AppxManifest.xml or AppxBundleManifest.xml in the archive' }
|
|
198
|
+
$sr = New-Object IO.StreamReader($entry.Open())
|
|
199
|
+
try { $x = [xml]($sr.ReadToEnd().TrimStart([char]0xFEFF)) } finally { $sr.Dispose() }
|
|
200
|
+
$idn = $x.GetElementsByTagName('Identity') | Select-Object -First 1
|
|
201
|
+
if (-not $idn) { throw 'manifest has no Identity' }
|
|
202
|
+
return [pscustomobject]@{ name = [string]$idn.Name; publisher = [string]$idn.Publisher; version = [string]$idn.Version; kind = $kind }
|
|
203
|
+
} finally { $zip.Dispose() }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
# ================================================================== install: register / refresh the task
|
|
207
|
+
if ($Install) {
|
|
208
|
+
trap { Log ('UNHANDLED ERROR: ' + $_.Exception.Message + ' at line ' + $_.InvocationInfo.ScriptLineNumber); Write-Result 'error' 4 $_.Exception.Message; exit 4 }
|
|
209
|
+
if (-not $Pfn) { Finish 'misconfigured' 2 'install refused: no -Pfn' }
|
|
210
|
+
if ($Pfn -notmatch $PfnPattern) { Finish 'refused' 2 ('install refused: -Pfn is not a package family name: ' + $Pfn) }
|
|
211
|
+
$prev = Read-Config
|
|
212
|
+
$feedUri = $Feed
|
|
213
|
+
try {
|
|
214
|
+
[void][Windows.Management.Deployment.PackageManager, Windows.Management.Deployment, ContentType = WindowsRuntime]
|
|
215
|
+
$pm = New-Object Windows.Management.Deployment.PackageManager
|
|
216
|
+
foreach ($x in @($pm.FindPackagesForUser('', $Pfn))) {
|
|
217
|
+
$ai = $x.GetAppInstallerInfo()
|
|
218
|
+
if ($ai -and $ai.Uri) { $feedUri = [string]$ai.Uri; break }
|
|
219
|
+
}
|
|
220
|
+
} catch { Log ('App Installer info unreadable: ' + $_.Exception.Message) }
|
|
221
|
+
if (-not $feedUri -and $prev -and $prev.feed) { $feedUri = [string]$prev.feed; Log 'package has no App Installer association; keeping the recorded feed' }
|
|
222
|
+
$msix = $(if ($prev -and $prev.msix) { [string]$prev.msix } else { '' })
|
|
223
|
+
if ($feedUri -and -not (Test-AllowedUri $feedUri)) { Finish 'refused' 2 ('install refused: feed uri is not https (or http to loopback): ' + $feedUri) }
|
|
224
|
+
if ($feedUri) {
|
|
225
|
+
try {
|
|
226
|
+
$fi = Get-FeedInfo $feedUri
|
|
227
|
+
if ($fi.uri) { if (Test-AllowedUri $fi.uri) { $msix = $fi.uri } else { Log ('feed msix uri not recorded (not https): ' + $fi.uri) } }
|
|
228
|
+
} catch { Log ('feed not readable at install time (' + $feedUri + '): ' + $_.Exception.Message) }
|
|
229
|
+
} else { Log 'no App Installer feed known: repair can only use a recorded msix uri' }
|
|
230
|
+
if ($msix -and -not (Test-AllowedUri $msix)) { Finish 'refused' 2 ('install refused: msix uri is not https (or http to loopback): ' + $msix) }
|
|
231
|
+
|
|
232
|
+
# expected identity: what is installed now (this run is started by the packaged app), else what was recorded
|
|
233
|
+
$name = $(if ($prev -and $prev.name) { [string]$prev.name } else { '' })
|
|
234
|
+
$publisher = $(if ($prev -and $prev.publisher) { [string]$prev.publisher } else { '' })
|
|
235
|
+
$pk = @(Get-Family $Pfn) | Select-Object -First 1
|
|
236
|
+
if ($pk) { $name = [string]$pk.Name; $publisher = [string]$pk.Publisher }
|
|
237
|
+
if (-not $name -or -not $publisher) { Log 'WARNING: package identity (Name, Publisher) unknown; the repair will refuse every source until a launch of the installed app records it' }
|
|
238
|
+
|
|
239
|
+
$taskName = $Pfn + '-Repair'
|
|
240
|
+
try { Register-GuardTask $Pfn (Get-RetryLocal) } catch { Finish 'misconfigured' 2 ('task registration failed: ' + $_.Exception.Message) }
|
|
241
|
+
$cfg = [ordered]@{ guardVersion = $GuardVersion; pfn = $Pfn; name = $name; publisher = $publisher; feed = $feedUri; msix = $msix; task = $taskName; dir = $Dir; installedUtc = (Get-Date).ToUniversalTime().ToString('o') }
|
|
242
|
+
[IO.File]::WriteAllText($ConfigPath, ($cfg | ConvertTo-Json), (New-Object Text.UTF8Encoding($false)))
|
|
243
|
+
try { Remove-Item -LiteralPath $AttemptPath -Force -ErrorAction SilentlyContinue } catch { }
|
|
244
|
+
Log ('task ' + $taskName + ' registered; identity=' + $name + ' / ' + $publisher + '; feed=' + $(if ($feedUri) { $feedUri } else { '(none)' }) + ' msix=' + $(if ($msix) { $msix } else { '(none)' }))
|
|
245
|
+
Write-Result 'installed' 0 ('task ' + $taskName + ' registered')
|
|
246
|
+
exit 0
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
# ================================================================== repair (task action)
|
|
250
|
+
trap {
|
|
251
|
+
Log ('UNHANDLED ERROR: ' + $_.Exception.Message + ' at line ' + $_.InvocationInfo.ScriptLineNumber)
|
|
252
|
+
Write-Result 'error' 4 $_.Exception.Message
|
|
253
|
+
if ($script:Ctx) { try { Set-Retry $script:Ctx 'unhandled error' } catch { } }
|
|
254
|
+
exit 4
|
|
255
|
+
}
|
|
256
|
+
$cfg = Read-Config
|
|
257
|
+
if (-not $cfg -or -not $cfg.pfn) { Finish 'misconfigured' 2 'guard.json missing or unreadable; nothing to do' }
|
|
258
|
+
$family = [string]$cfg.pfn
|
|
259
|
+
$feedUri = [string]$cfg.feed
|
|
260
|
+
$msix = [string]$cfg.msix
|
|
261
|
+
if ($family -notmatch $PfnPattern) { Finish 'refused' 2 ('guard.json pfn is not a package family name: ' + $family) }
|
|
262
|
+
if ([string]$cfg.task -ne ($family + '-Repair')) { Finish 'refused' 2 ('guard.json task ' + [string]$cfg.task + ' is not ' + $family + '-Repair') }
|
|
263
|
+
if ((Split-Path -Leaf $Dir) -ne 'repair') { Finish 'refused' 2 ('script folder is not named repair: ' + $Dir) }
|
|
264
|
+
if ($feedUri -and -not (Test-AllowedUri $feedUri)) { Finish 'refused' 2 ('guard.json feed uri is not https (or http to loopback): ' + $feedUri) }
|
|
265
|
+
if ($msix -and -not (Test-AllowedUri $msix)) { Finish 'refused' 2 ('guard.json msix uri is not https (or http to loopback): ' + $msix) }
|
|
266
|
+
$script:Ctx = $family
|
|
267
|
+
$mutex = [Threading.Mutex]::new($false, ('Local\' + $family + '-Repair'))
|
|
268
|
+
if (-not $mutex.WaitOne(0)) { Log 'another repair run is active; exiting (last-result.json left to that run)'; exit 0 }
|
|
269
|
+
$tmpMsix = ''
|
|
270
|
+
try {
|
|
271
|
+
$state = Get-StateText $family
|
|
272
|
+
if ($state -eq 'absent') {
|
|
273
|
+
Start-Sleep -Seconds 10 # an update or reinstall can briefly show no package; require absence twice
|
|
274
|
+
$state = Get-StateText $family
|
|
275
|
+
}
|
|
276
|
+
if ($state -eq 'absent') {
|
|
277
|
+
# uninstalled: remove the repair folder (never its parent: that holds user data), then the task itself (last: it ends this run)
|
|
278
|
+
$note = Join-Path $env:TEMP ($family + '-repair-cleanup.log')
|
|
279
|
+
try { Add-Content -LiteralPath $note -Value ((Get-Date).ToString('o') + ' package ' + $family + ' absent; removing task ' + $cfg.task + ' and ' + $Dir) } catch { }
|
|
280
|
+
try { Remove-Item -LiteralPath $Dir -Recurse -Force -ErrorAction Stop } catch { try { Add-Content -LiteralPath $note -Value ('folder removal failed: ' + $_.Exception.Message) } catch { } }
|
|
281
|
+
try { Unregister-ScheduledTask -TaskName ([string]$cfg.task) -Confirm:$false -ErrorAction Stop; Add-Content -LiteralPath $note -Value 'task unregistered' } catch { try { Add-Content -LiteralPath $note -Value ('task removal failed: ' + $_.Exception.Message) } catch { } }
|
|
282
|
+
exit 0
|
|
283
|
+
}
|
|
284
|
+
if (Test-Healthy $family) { Clear-Retry $family; Finish 'healthy' 0 ('package healthy: ' + $state) }
|
|
285
|
+
Log ('package NOT healthy: ' + $state + '; letting the deployment service settle')
|
|
286
|
+
Start-Sleep -Seconds 8
|
|
287
|
+
if (Test-Healthy $family) { Clear-Retry $family; Finish 'healthy' 0 ('package recovered by itself: ' + (Get-StateText $family)) }
|
|
288
|
+
|
|
289
|
+
if (-not $feedUri -and -not $msix) { Finish 'no-source' 3 'FAILED: no App Installer feed and no msix uri recorded; cannot repair. Reinstall from the original source or use Settings > Apps > Advanced options > Repair.' }
|
|
290
|
+
# expected identity: recorded at install; fall back to the (broken) installed registration, which still reports Name and Publisher
|
|
291
|
+
$expect = [pscustomobject]@{ name = [string]$cfg.name; publisher = [string]$cfg.publisher }
|
|
292
|
+
if (-not $expect.name -or -not $expect.publisher) {
|
|
293
|
+
$pk = @(Get-Family $family) | Select-Object -First 1
|
|
294
|
+
if ($pk) { $expect.name = [string]$pk.Name; $expect.publisher = [string]$pk.Publisher }
|
|
295
|
+
}
|
|
296
|
+
if (-not $expect.name -or -not $expect.publisher) {
|
|
297
|
+
Log 'FAILED: expected package identity unknown; refusing to install anything'
|
|
298
|
+
Set-Retry $family 'identity unknown'
|
|
299
|
+
Write-Result 'failed' 1 'expected package identity unknown'
|
|
300
|
+
exit 1
|
|
301
|
+
}
|
|
302
|
+
$why = @()
|
|
303
|
+
$feedOk = $false
|
|
304
|
+
if ($feedUri) {
|
|
305
|
+
$fi = $null
|
|
306
|
+
try { $fi = Get-FeedInfo $feedUri } catch { Log ('feed unreachable, skipping -AppInstallerFile: ' + $feedUri + ' : ' + $_.Exception.Message); $why += ('feed unreachable: ' + $_.Exception.Message) }
|
|
307
|
+
if ($fi) {
|
|
308
|
+
$bad = Test-Identity $expect $fi.name $fi.publisher
|
|
309
|
+
if (-not $bad -and $fi.uri -and -not (Test-AllowedUri $fi.uri)) { $bad = 'feed msix uri is not https (or http to loopback): ' + $fi.uri }
|
|
310
|
+
if ($bad) { Log ('REFUSED feed ' + $feedUri + ': ' + $bad); $why += ('feed refused: ' + $bad) }
|
|
311
|
+
else {
|
|
312
|
+
$feedOk = $true
|
|
313
|
+
if ($fi.uri) { $msix = $fi.uri } # newer than the one recorded
|
|
314
|
+
Log ('Add-AppxPackage -AppInstallerFile ' + $feedUri + ' (declares ' + $fi.version + ')')
|
|
315
|
+
try { Add-AppxPackage -AppInstallerFile $feedUri -ErrorAction Stop } catch { Log ('-AppInstallerFile failed: ' + $_.Exception.Message + ' | ' + $_.FullyQualifiedErrorId); $why += ('-AppInstallerFile failed: ' + $_.Exception.Message) }
|
|
316
|
+
if (Test-Healthy $family) {
|
|
317
|
+
$now = @(Get-Family $family) | Select-Object -First 1
|
|
318
|
+
$bad = Test-Identity $expect ([string]$now.Name) ([string]$now.Publisher)
|
|
319
|
+
if ($bad) { Log ('FAILED: identity mismatch after repair via feed: ' + $bad); $why += ('identity after repair: ' + $bad) }
|
|
320
|
+
else { Clear-Retry $family; Finish 'repaired' 0 ('REPAIRED via feed: ' + (Get-StateText $family)) }
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if ($msix) {
|
|
326
|
+
$tmpMsix = Join-Path $env:TEMP ($family + '-repair-' + [guid]::NewGuid().ToString('N') + '.msix')
|
|
327
|
+
$got = $false
|
|
328
|
+
try { Log ('downloading ' + $msix); Save-Msix $msix $tmpMsix; $got = $true } catch { Log ('msix unreachable, skipping -Path: ' + $msix + ' : ' + $_.Exception.Message); $why += ('msix unreachable: ' + $_.Exception.Message) }
|
|
329
|
+
if ($got) {
|
|
330
|
+
$mi = $null
|
|
331
|
+
try { $mi = Get-MsixIdentity $tmpMsix } catch { Log ('REFUSED msix: corrupt or unreadable package: ' + $_.Exception.Message); $why += ('msix corrupt: ' + $_.Exception.Message) }
|
|
332
|
+
if ($mi) {
|
|
333
|
+
$bad = Test-Identity $expect $mi.name $mi.publisher
|
|
334
|
+
if ($bad) { Log ('REFUSED msix ' + $msix + ': ' + $bad); $why += ('msix refused: ' + $bad) }
|
|
335
|
+
else {
|
|
336
|
+
Log ('Add-AppxPackage -Path ' + $msix + ' (' + $mi.kind + ' ' + $mi.version + ')')
|
|
337
|
+
try { Add-AppxPackage -Path $tmpMsix -ErrorAction Stop } catch { Log ('-Path failed: ' + $_.Exception.Message + ' | ' + $_.FullyQualifiedErrorId); $why += ('-Path failed: ' + $_.Exception.Message) }
|
|
338
|
+
if (Test-Healthy $family) {
|
|
339
|
+
$now = @(Get-Family $family) | Select-Object -First 1
|
|
340
|
+
$bad = Test-Identity $expect ([string]$now.Name) ([string]$now.Publisher)
|
|
341
|
+
if ($bad) { Log ('FAILED: identity mismatch after repair via msix: ' + $bad); $why += ('identity after repair: ' + $bad) }
|
|
342
|
+
else { Clear-Retry $family; Finish 'repaired' 0 ('REPAIRED via msix uri: ' + (Get-StateText $family)) }
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
} else { $why += 'no msix uri recorded' }
|
|
348
|
+
$detail = 'FAILED: package still ' + (Get-StateText $family) + '. Reasons: ' + ($why -join ' | ')
|
|
349
|
+
Set-Retry $family 'repair failed'
|
|
350
|
+
Write-Result 'failed' 1 $detail
|
|
351
|
+
Log ($detail + '. A retry is scheduled (see retry.json); manual: Settings > Apps > Advanced options > Repair.')
|
|
352
|
+
exit 1
|
|
353
|
+
} finally {
|
|
354
|
+
if ($tmpMsix) { try { Remove-Item -LiteralPath $tmpMsix -Force -ErrorAction SilentlyContinue } catch { } }
|
|
355
|
+
try { $mutex.ReleaseMutex() } catch { }
|
|
356
|
+
}
|
package/msix-signer.mjs
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// Default signer-subject reader for .msix / .msixbundle.
|
|
2
|
+
// PowerShell Get-AuthenticodeSignature returns nothing for MSIX on hosted Windows runners, so the subject
|
|
3
|
+
// is read from the package's own AppxSignature.p7x: a "PKCX" magic followed by a PKCS#7 SignedData.
|
|
4
|
+
// Pure Node (node:zlib + node:crypto X509Certificate + a small DER walker); no dependency, any host.
|
|
5
|
+
// The certificate chosen is the one named by SignerInfo issuerAndSerialNumber (or subjectKeyIdentifier),
|
|
6
|
+
// never merely the first certificate. Every failure throws MSIX_PUBLISHER_MISMATCH (fail closed).
|
|
7
|
+
|
|
8
|
+
import { X509Certificate } from "node:crypto";
|
|
9
|
+
import { closeSync, fstatSync, openSync, readSync } from "node:fs";
|
|
10
|
+
import { inflateRawSync } from "node:zlib";
|
|
11
|
+
|
|
12
|
+
const fail = (message) => Object.assign(new Error(message), { code: "MSIX_PUBLISHER_MISMATCH" });
|
|
13
|
+
const MAX_P7X = 4 * 1024 * 1024;
|
|
14
|
+
const SIGNED_DATA_OID = "1.2.840.113549.1.7.2";
|
|
15
|
+
|
|
16
|
+
// --- zip container -----------------------------------------------------------------------------
|
|
17
|
+
const u64 = (b, o) => Number(b.readBigUInt64LE(o));
|
|
18
|
+
|
|
19
|
+
function readAt(fd, position, length) {
|
|
20
|
+
const buffer = Buffer.alloc(length);
|
|
21
|
+
let done = 0;
|
|
22
|
+
while (done < length) {
|
|
23
|
+
const n = readSync(fd, buffer, done, length - done, position + done);
|
|
24
|
+
if (n === 0) break;
|
|
25
|
+
done += n;
|
|
26
|
+
}
|
|
27
|
+
return buffer.subarray(0, done);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Return the bytes of the named entry of a zip file, or null when absent. */
|
|
31
|
+
export function readZipEntry(file, wanted, { maxBytes = MAX_P7X } = {}) {
|
|
32
|
+
const fd = openSync(file, "r");
|
|
33
|
+
try {
|
|
34
|
+
const size = fstatSync(fd).size;
|
|
35
|
+
const tailLength = Math.min(size, 22 + 65535);
|
|
36
|
+
const tail = readAt(fd, size - tailLength, tailLength);
|
|
37
|
+
let entries; let dirSize; let dirOffset; let found = false;
|
|
38
|
+
for (let i = tail.length - 22; i >= 0; i -= 1) {
|
|
39
|
+
if (tail.readUInt32LE(i) !== 0x06054b50) continue;
|
|
40
|
+
if (i + 22 + tail.readUInt16LE(i + 20) > tail.length) continue;
|
|
41
|
+
entries = tail.readUInt16LE(i + 10); dirSize = tail.readUInt32LE(i + 12); dirOffset = tail.readUInt32LE(i + 16);
|
|
42
|
+
if (entries === 0xffff || dirSize === 0xffffffff || dirOffset === 0xffffffff) {
|
|
43
|
+
const at = size - tailLength + i;
|
|
44
|
+
const loc = at >= 20 ? readAt(fd, at - 20, 20) : Buffer.alloc(0);
|
|
45
|
+
if (loc.length < 20 || loc.readUInt32LE(0) !== 0x07064b50) throw new Error("zip64 locator missing");
|
|
46
|
+
const rec = readAt(fd, u64(loc, 8), 56);
|
|
47
|
+
if (rec.length < 56 || rec.readUInt32LE(0) !== 0x06064b50) throw new Error("zip64 end record missing");
|
|
48
|
+
entries = u64(rec, 32); dirSize = u64(rec, 40); dirOffset = u64(rec, 48);
|
|
49
|
+
}
|
|
50
|
+
found = true;
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
if (!found) throw new Error("not a zip container (end of central directory not found)");
|
|
54
|
+
if (dirSize > 256 * 1024 * 1024 || dirOffset + dirSize > size) throw new Error("central directory out of bounds");
|
|
55
|
+
const dir = readAt(fd, dirOffset, dirSize);
|
|
56
|
+
if (dir.length !== dirSize) throw new Error("central directory truncated");
|
|
57
|
+
let at = 0;
|
|
58
|
+
for (let n = 0; n < entries; n += 1) {
|
|
59
|
+
if (at + 46 > dir.length || dir.readUInt32LE(at) !== 0x02014b50) throw new Error("bad central directory entry");
|
|
60
|
+
const flags = dir.readUInt16LE(at + 8); const method = dir.readUInt16LE(at + 10);
|
|
61
|
+
let compressed = dir.readUInt32LE(at + 20); let uncompressed = dir.readUInt32LE(at + 24);
|
|
62
|
+
const nameLen = dir.readUInt16LE(at + 28); const extraLen = dir.readUInt16LE(at + 30); const commentLen = dir.readUInt16LE(at + 32);
|
|
63
|
+
let local = dir.readUInt32LE(at + 42);
|
|
64
|
+
const end = at + 46 + nameLen + extraLen + commentLen;
|
|
65
|
+
if (end > dir.length) throw new Error("central directory entry out of bounds");
|
|
66
|
+
const name = dir.subarray(at + 46, at + 46 + nameLen).toString("utf8");
|
|
67
|
+
if (name === wanted) {
|
|
68
|
+
const extra = dir.subarray(at + 46 + nameLen, at + 46 + nameLen + extraLen);
|
|
69
|
+
for (let e = 0; e + 4 <= extra.length; e += 4 + extra.readUInt16LE(e + 2)) {
|
|
70
|
+
if (extra.readUInt16LE(e) !== 0x0001) continue;
|
|
71
|
+
let p = e + 4;
|
|
72
|
+
if (uncompressed === 0xffffffff) { uncompressed = u64(extra, p); p += 8; }
|
|
73
|
+
if (compressed === 0xffffffff) { compressed = u64(extra, p); p += 8; }
|
|
74
|
+
if (local === 0xffffffff) local = u64(extra, p);
|
|
75
|
+
}
|
|
76
|
+
if (flags & 1) throw new Error(`${wanted} is encrypted`);
|
|
77
|
+
if (uncompressed > maxBytes || compressed > maxBytes) throw new Error(`${wanted} larger than ${maxBytes} bytes`);
|
|
78
|
+
const lh = readAt(fd, local, 30);
|
|
79
|
+
if (lh.length < 30 || lh.readUInt32LE(0) !== 0x04034b50) throw new Error("bad local header");
|
|
80
|
+
const start = local + 30 + lh.readUInt16LE(26) + lh.readUInt16LE(28);
|
|
81
|
+
if (start + compressed > size) throw new Error("entry data out of bounds");
|
|
82
|
+
const raw = readAt(fd, start, compressed);
|
|
83
|
+
let data;
|
|
84
|
+
if (method === 0) data = raw;
|
|
85
|
+
else if (method === 8) data = inflateRawSync(raw, { maxOutputLength: maxBytes });
|
|
86
|
+
else throw new Error(`unsupported zip method ${method}`);
|
|
87
|
+
if (data.length !== uncompressed) throw new Error(`${wanted} length differs from its directory entry`);
|
|
88
|
+
return data;
|
|
89
|
+
}
|
|
90
|
+
at = end;
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
} finally { closeSync(fd); }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// --- DER ---------------------------------------------------------------------------------------
|
|
97
|
+
/** Read one TLV at `at`; returns { tag, start (of header), body (start), end }. */
|
|
98
|
+
function tlv(buf, at, limit = buf.length) {
|
|
99
|
+
if (at + 2 > limit) throw new Error("DER truncated");
|
|
100
|
+
const tag = buf[at]; let len = buf[at + 1]; let body = at + 2;
|
|
101
|
+
if ((tag & 0x1f) === 0x1f) throw new Error("DER high tag numbers unsupported");
|
|
102
|
+
if (len & 0x80) {
|
|
103
|
+
const n = len & 0x7f;
|
|
104
|
+
if (n === 0) return indefinite(buf, tag, at, limit); // BER indefinite length
|
|
105
|
+
if (n > 4 || body + n > limit) throw new Error("DER bad length");
|
|
106
|
+
len = 0;
|
|
107
|
+
for (let i = 0; i < n; i += 1) len = len * 256 + buf[body + i];
|
|
108
|
+
body += n;
|
|
109
|
+
}
|
|
110
|
+
if (body + len > limit) throw new Error("DER length exceeds container");
|
|
111
|
+
return { tag, start: at, body, end: body + len };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// BER indefinite-length constructed value: children until 00 00.
|
|
115
|
+
function indefinite(buf, tag, at, limit) {
|
|
116
|
+
if (!(tag & 0x20)) throw new Error("indefinite length on primitive");
|
|
117
|
+
const body = at + 2; let p = body;
|
|
118
|
+
while (true) {
|
|
119
|
+
if (p + 2 > limit) throw new Error("BER unterminated");
|
|
120
|
+
if (buf[p] === 0 && buf[p + 1] === 0) return { tag, start: at, body, end: p, tail: p + 2 };
|
|
121
|
+
p = next(tlv(buf, p, limit));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const next = (t) => t.tail ?? t.end;
|
|
125
|
+
const children = (buf, t) => {
|
|
126
|
+
const out = []; let p = t.body;
|
|
127
|
+
while (p < t.end) { const c = tlv(buf, p, t.end); out.push(c); p = next(c); }
|
|
128
|
+
return out;
|
|
129
|
+
};
|
|
130
|
+
const whole = (buf, t) => buf.subarray(t.start, next(t));
|
|
131
|
+
const oid = (buf, t) => {
|
|
132
|
+
const b = buf.subarray(t.body, t.end); const parts = [Math.floor(b[0] / 40), b[0] % 40]; let v = 0;
|
|
133
|
+
for (let i = 1; i < b.length; i += 1) { v = v * 128 + (b[i] & 0x7f); if (!(b[i] & 0x80)) { parts.push(v); v = 0; } }
|
|
134
|
+
return parts.join(".");
|
|
135
|
+
};
|
|
136
|
+
const intHex = (buf, t) => { let h = buf.subarray(t.body, t.end).toString("hex").replace(/^(00)+(?=.)/, ""); return h.toLowerCase(); };
|
|
137
|
+
|
|
138
|
+
/** Parse PKCS#7 SignedData; return { certificates: [Buffer], signer: { issuer: Buffer, serial: string } | { ski: string } }. */
|
|
139
|
+
export function parseSignedData(der) {
|
|
140
|
+
const ci = tlv(der, 0);
|
|
141
|
+
if (ci.tag !== 0x30) throw new Error("ContentInfo is not a SEQUENCE");
|
|
142
|
+
const [type, content] = children(der, ci);
|
|
143
|
+
if (!type || type.tag !== 0x06 || oid(der, type) !== SIGNED_DATA_OID) throw new Error("not a PKCS#7 SignedData");
|
|
144
|
+
if (!content || content.tag !== 0xa0) throw new Error("SignedData content missing");
|
|
145
|
+
const sd = children(der, content)[0];
|
|
146
|
+
if (!sd || sd.tag !== 0x30) throw new Error("SignedData is not a SEQUENCE");
|
|
147
|
+
const parts = children(der, sd);
|
|
148
|
+
const certsSet = parts.find((p) => p.tag === 0xa0);
|
|
149
|
+
const signerSet = parts.filter((p) => p.tag === 0x31).at(-1);
|
|
150
|
+
if (!certsSet) throw new Error("SignedData carries no certificates");
|
|
151
|
+
if (!signerSet) throw new Error("SignedData carries no SignerInfos");
|
|
152
|
+
const certificates = children(der, certsSet).filter((c) => c.tag === 0x30).map((c) => Buffer.from(whole(der, c)));
|
|
153
|
+
const infos = children(der, signerSet);
|
|
154
|
+
if (infos.length !== 1) throw new Error(`expected exactly one SignerInfo, found ${infos.length}`);
|
|
155
|
+
const [, sid] = children(der, infos[0]);
|
|
156
|
+
if (!sid) throw new Error("SignerInfo has no signer identifier");
|
|
157
|
+
let signer;
|
|
158
|
+
if (sid.tag === 0x30) {
|
|
159
|
+
const [issuer, serial] = children(der, sid);
|
|
160
|
+
signer = { issuer: Buffer.from(whole(der, issuer)), serial: intHex(der, serial) };
|
|
161
|
+
} else if (sid.tag === 0x80) signer = { ski: Buffer.from(der.subarray(sid.body, sid.end)).toString("hex") };
|
|
162
|
+
else throw new Error("unsupported SignerInfo signer identifier");
|
|
163
|
+
return { certificates, signer };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** TBSCertificate issuer (DER) and serial (hex) of a certificate. */
|
|
167
|
+
function certIdentity(cert) {
|
|
168
|
+
const [tbs] = children(cert, tlv(cert, 0));
|
|
169
|
+
const f = children(cert, tbs);
|
|
170
|
+
const i = f[0].tag === 0xa0 ? 1 : 0; // optional [0] version
|
|
171
|
+
return { serial: intHex(cert, f[i]), issuer: Buffer.from(whole(cert, f[i + 2])) };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function signerCertificate(parsed) {
|
|
175
|
+
const match = parsed.certificates.filter((c) => {
|
|
176
|
+
try {
|
|
177
|
+
if (parsed.signer.ski) {
|
|
178
|
+
const x = new X509Certificate(c);
|
|
179
|
+
return x.raw && (x.subjectKeyIdentifier ?? "").replace(/:/g, "").toLowerCase() === parsed.signer.ski;
|
|
180
|
+
}
|
|
181
|
+
const id = certIdentity(c);
|
|
182
|
+
return id.serial === parsed.signer.serial && id.issuer.equals(parsed.signer.issuer);
|
|
183
|
+
} catch { return false; }
|
|
184
|
+
});
|
|
185
|
+
if (match.length !== 1) throw new Error(`signer certificate not found in the signature (${match.length} matches)`);
|
|
186
|
+
return new X509Certificate(match[0]);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// --- subject rendering ---------------------------------------------------------------------------
|
|
190
|
+
const needsQuote = (v) => /[,+"\\<>;=\r\n]/.test(v) || /^[ #]|\s$/.test(v);
|
|
191
|
+
/** Node renders subject as "K=V" lines; join as an RFC 2253-style display string like .NET Subject ("CN=a, O=b"). */
|
|
192
|
+
export function renderSubject(x509) {
|
|
193
|
+
return String(x509.subject).split("\n").filter(Boolean).map((line) => {
|
|
194
|
+
const at = line.indexOf("=");
|
|
195
|
+
const key = line.slice(0, at); const value = line.slice(at + 1);
|
|
196
|
+
return `${key}=${needsQuote(value) ? `"${value.replace(/"/g, '""')}"` : value}`;
|
|
197
|
+
}).join(", ");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Read the signer certificate subject of a signed .msix / .msixbundle from AppxSignature.p7x. Throws MSIX_PUBLISHER_MISMATCH. */
|
|
201
|
+
export function readMsixSignerSubject(file) {
|
|
202
|
+
let p7x;
|
|
203
|
+
try { p7x = readZipEntry(file, "AppxSignature.p7x"); } catch (error) { throw fail(`could not read the signer certificate subject: ${error.message}`); }
|
|
204
|
+
if (!p7x) throw fail("could not read the signer certificate subject: AppxSignature.p7x is missing (package is unsigned)");
|
|
205
|
+
try {
|
|
206
|
+
if (p7x.length < 5 || p7x.subarray(0, 4).toString("latin1") !== "PKCX") throw new Error("AppxSignature.p7x lacks the PKCX header");
|
|
207
|
+
return renderSubject(signerCertificate(parseSignedData(p7x.subarray(4))));
|
|
208
|
+
} catch (error) {
|
|
209
|
+
throw fail(`could not read the signer certificate subject: AppxSignature.p7x unparsable (${error.message})`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -3,16 +3,15 @@
|
|
|
3
3
|
"newCapabilityApprovalFlag": "--approve-new-capabilities",
|
|
4
4
|
"approvalScope": "Local catalog generation only; installation still requires protected authorization. This flag cannot approve owner changes.",
|
|
5
5
|
"ownerFamilies": [
|
|
6
|
-
{ "capability": "agent-room", "packages": ["agent-room-core", "@rightkit/agent-room-mcp"], "reason": "Native room core & MCP adapter share room contract" },
|
|
7
6
|
{ "capability": "licensing", "packages": ["rightkit-license", "@rightkit/license"], "reason": "Native entitlement enforcement & renderer/network client have separate roles" },
|
|
8
7
|
{ "capability": "structured-logging", "packages": ["rightkit-logs", "@rightkit/logs"], "reason": "Native JSONL writer & typed renderer invoke adapter" },
|
|
9
|
-
{ "capability": "text-to-speech", "packages": ["rightkit-
|
|
10
|
-
{ "capability": "os-credential-store", "packages": ["rightkit-
|
|
11
|
-
{ "capability": "desktop-shell", "packages": ["rightkit-shell", "@rightkit/shell"], "reason": "Native Tauri shell & typed renderer bridge" },
|
|
12
|
-
{ "capability": "tauri-host-adapters", "packages": ["rightkit-
|
|
8
|
+
{ "capability": "text-to-speech", "packages": ["rightkit-tts-qwen", "rightkit-tts-qwen-sys", "rightkit-tts"], "reason": "Speech facade & its QTS inference, safe-wrapper & FFI layers" },
|
|
9
|
+
{ "capability": "os-credential-store", "packages": ["rightkit-credentials", "RightKitSwift"], "reason": "Cross-platform Rust & Apple-native Swift credential APIs" },
|
|
10
|
+
{ "capability": "desktop-shell", "packages": ["rightkit-tauri-shell", "@rightkit/tauri-shell"], "reason": "Native Tauri shell & typed renderer bridge" },
|
|
11
|
+
{ "capability": "tauri-host-adapters", "packages": ["rightkit-device", "@rightkit/tauri"], "reason": "Native license/device services & renderer command adapters" }
|
|
13
12
|
],
|
|
14
13
|
"testOwners": [
|
|
15
|
-
{ "path": "crates/rightkit-
|
|
16
|
-
{ "path": "crates/rightkit-shell/example-app/Cargo.toml", "package": "shell-example-app", "reason": "Unpublished standalone shell demonstration & test host" }
|
|
14
|
+
{ "path": "crates/rightkit-qa-driver/examples/control-test-app/Cargo.toml", "package": "control-test-app", "reason": "Unpublished desktop-control test fixture" },
|
|
15
|
+
{ "path": "crates/rightkit-tauri-shell/example-app/Cargo.toml", "package": "shell-example-app", "reason": "Unpublished standalone shell demonstration & test host" }
|
|
17
16
|
]
|
|
18
17
|
}
|