diff --git a/.gitea/workflows/build-iso-windows.yaml b/.gitea/workflows/build-iso-windows.yaml
new file mode 100644
index 0000000..a02a4d9
--- /dev/null
+++ b/.gitea/workflows/build-iso-windows.yaml
@@ -0,0 +1,131 @@
+name: Build SilverMetal Enhanced - Windows ISO
+
+# M2/M3. Builds the custom packed ISO on the self-hosted Windows runner
+# (silverlabs-runner-win, labels windows-latest / windows-2025), validates the
+# baked payload offline (no nested-virt needed), and on a tag attaches the ISO
+# + SBOM + SHA256 to a Gitea release. Mirrors build-iso-linux.yaml's structure.
+#
+# Base ISO: the licensed/eval Windows 11 IoT Enterprise LTSC media is an INPUT,
+# never committed. Provide it via the repo variable SILVERMETAL_BASE_ISO_URL
+# (downloaded at build time) OR pre-stage it on the runner at C:\silvermetal\base.iso.
+#
+# Reproducibility scope (iso-builder.md §5): pinned inputs + SBOM + SHA, NOT
+# bit-identical on Windows -- so this is single-build, not the Linux double-build gate.
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - 'windows/**'
+ - '.gitea/workflows/build-iso-windows.yaml'
+ tags:
+ - 'win-v*'
+ pull_request:
+ branches: [main]
+ paths:
+ - 'windows/**'
+ - '.gitea/workflows/build-iso-windows.yaml'
+ workflow_dispatch:
+
+concurrency:
+ group: build-iso-windows-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ build:
+ runs-on: windows-latest
+ timeout-minutes: 120
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Ensure Windows ADK (oscdimg)
+ shell: pwsh
+ run: |
+ $deploy = 'C:\Program Files (x86)\Windows Kits\10\Assessment and Deployment Kit\Deployment Tools'
+ if ((Get-Command oscdimg.exe -EA SilentlyContinue) -or (Test-Path $deploy)) {
+ Write-Host 'ADK Deployment Tools already present.'; exit 0
+ }
+ Write-Host 'Installing ADK Deployment Tools...'
+ # Prefer winget; fall back to the ADK web installer.
+ $ok = $false
+ try { winget install --id Microsoft.WindowsADK -e --accept-source-agreements --accept-package-agreements --silent; $ok = $true } catch {}
+ if (-not $ok) {
+ # NOTE: fwlink id is ADK-version-specific; update if the channel rolls.
+ Invoke-WebRequest 'https://go.microsoft.com/fwlink/?linkid=2289980' -OutFile "$env:TEMP\adksetup.exe"
+ Start-Process "$env:TEMP\adksetup.exe" -ArgumentList '/quiet','/norestart','/features','OptionId.DeploymentTools' -Wait
+ }
+ if (-not (Test-Path $deploy)) { throw 'ADK Deployment Tools install failed.' }
+
+ - name: Acquire base ISO
+ id: iso
+ shell: pwsh
+ env:
+ ISO_URL: ${{ vars.SILVERMETAL_BASE_ISO_URL }}
+ run: |
+ $staged = 'C:\silvermetal\base.iso'
+ if ($env:ISO_URL) {
+ $dst = "$env:RUNNER_TEMP\base.iso"
+ Write-Host "Downloading base ISO from repo variable URL..."
+ Invoke-WebRequest $env:ISO_URL -OutFile $dst
+ "path=$dst" >> $env:GITHUB_OUTPUT
+ } elseif (Test-Path $staged) {
+ Write-Host "Using pre-staged ISO: $staged"
+ "path=$staged" >> $env:GITHUB_OUTPUT
+ } else {
+ throw "No base ISO. Set repo variable SILVERMETAL_BASE_ISO_URL or stage it at $staged on the runner."
+ }
+
+ - name: Build packed ISO
+ shell: pwsh
+ run: |
+ .\windows\installer\build.ps1 `
+ -SourceIso '${{ steps.iso.outputs.path }}' `
+ -OutputIso "$env:RUNNER_TEMP\out\SilverMetal-Enhanced-Windows.iso" `
+ -SkipInputVerify
+
+ - name: Validate baked payload (offline assertions)
+ shell: pwsh
+ run: |
+ .\windows\tests\Assert-IsoStructure.ps1 -IsoPath "$env:RUNNER_TEMP\out\SilverMetal-Enhanced-Windows.iso"
+
+ - name: Upload SBOM + SHA (always)
+ uses: actions/upload-artifact@v3
+ with:
+ name: silvermetal-windows-attestation-${{ github.run_id }}
+ path: |
+ ${{ runner.temp }}/out/*.sbom.json
+ ${{ runner.temp }}/out/*.sha256
+ if-no-files-found: warn
+ retention-days: 30
+
+ - name: Upload ISO (dispatch / tag only)
+ if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/')
+ uses: actions/upload-artifact@v3
+ with:
+ name: silvermetal-windows-iso-${{ github.run_id }}
+ path: ${{ runner.temp }}/out/*.iso
+ if-no-files-found: error
+ retention-days: 14
+
+ - name: Attach to Gitea release (tag only)
+ if: startsWith(github.ref, 'refs/tags/')
+ shell: pwsh
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ $api = "${{ github.server_url }}/api/v1"
+ $repo = "${{ github.repository }}"
+ $tag = "${{ github.ref_name }}"
+ $h = @{ Authorization = "token $env:GITHUB_TOKEN" }
+ $rel = try { Invoke-RestMethod -Headers $h "$api/repos/$repo/releases/tags/$tag" } catch { $null }
+ if (-not $rel) {
+ $body = @{ tag_name=$tag; name="SilverMetal Enhanced - Windows $tag"; body="Packed ISO + SBOM + SHA256. Reproducibility: pinned inputs + SBOM (not bit-identical)."; draft=$false; prerelease=$true } | ConvertTo-Json
+ $rel = Invoke-RestMethod -Method Post -Headers $h -ContentType 'application/json' -Body $body "$api/repos/$repo/releases"
+ }
+ Get-ChildItem "$env:RUNNER_TEMP\out\*" -Include *.iso,*.sbom.json,*.sha256 | ForEach-Object {
+ Write-Host "Attaching $($_.Name)"
+ Invoke-RestMethod -Method Post -Headers $h -ContentType 'application/octet-stream' `
+ -InFile $_.FullName "$api/repos/$repo/releases/$($rel.id)/assets?name=$($_.Name)"
+ }
diff --git a/windows/installer/.gitignore b/windows/installer/.gitignore
new file mode 100644
index 0000000..80be080
--- /dev/null
+++ b/windows/installer/.gitignore
@@ -0,0 +1,5 @@
+# Build outputs — never commit packed ISOs or attestations.
+out/
+*.iso
+*.iso.sha256
+*.iso.sbom.json
diff --git a/windows/installer/autounattend/autounattend.xml b/windows/installer/autounattend/autounattend.xml
index 29d92ef..af356e5 100644
--- a/windows/installer/autounattend/autounattend.xml
+++ b/windows/installer/autounattend/autounattend.xml
@@ -52,13 +52,14 @@
-
-
- 1
- cmd /c C:\Windows\Setup\Scripts\SetupComplete.cmd
- SilverMetal first-boot hardening
-
-
+
+ SilverMetal
+ SilverLABS
diff --git a/windows/installer/build.ps1 b/windows/installer/build.ps1
index 3612fcf..32499ce 100644
--- a/windows/installer/build.ps1
+++ b/windows/installer/build.ps1
@@ -6,22 +6,27 @@
.DESCRIPTION
Turns an official Windows 11 IoT Enterprise LTSC ISO (an INPUT, never
redistributed) plus the SilverMetal config layer into a hardened, branded,
- UEFI-bootable ISO with a signed build attestation.
+ UEFI-bootable ISO with an SBOM + SHA-256 attestation.
Design: ../iso-builder.md Controls: ../hardening-spec.md
- Build host requirement: Windows + Windows ADK (DISM + oscdimg).
+ Build host requirement: Windows x64 + Windows ADK (oscdimg) + DISM (built in).
+ Must run ELEVATED (DISM image mount requires admin).
.NOTES
- SCAFFOLD (M0). Stage bodies are stubbed; they are fleshed out at M2.
- Each stage maps 1:1 to iso-builder.md section 3.
+ M2 implementation. Validated by running on the silverlabs-runner-win Gitea
+ runner (this repo cannot be built on the ARM64 dev box). Offline policy
+ baking is intentionally minimal here -- the §A-H hardening runs at first
+ boot via SetupComplete.cmd; this pipeline injects drivers, debloat, the
+ hardening payload, the answer file, then repacks + attests.
#>
[CmdletBinding()]
param(
- [Parameter(Mandatory)] [string] $SourceIso, # licensed IoT Enterprise LTSC ISO
+ [Parameter(Mandatory)] [string] $SourceIso,
[string] $Manifest = "$PSScriptRoot\inputs.manifest.json",
[string] $WorkDir = "$env:TEMP\silvermetal-build",
[string] $OutputIso = "$PSScriptRoot\out\SilverMetal-Enhanced-Windows.iso",
+ [string] $WindowsDir = "$PSScriptRoot\..", # repo windows/ root
[switch] $SkipInputVerify
)
@@ -29,64 +34,136 @@ Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Write-Stage { param([string]$Msg) Write-Host "==> $Msg" -ForegroundColor Cyan }
+function Resolve-Tool {
+ param([string]$Name,[string[]]$Globs)
+ $c = Get-Command $Name -EA SilentlyContinue
+ if ($c) { return $c.Source }
+ foreach ($g in $Globs) { $p = Get-ChildItem $g -EA SilentlyContinue | Select-Object -First 1; if ($p) { return $p.FullName } }
+ throw "$Name not found. Install the Windows ADK Deployment Tools."
+}
+
+if (-not ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()
+ ).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
+ throw 'build.ps1 must run elevated (DISM image servicing requires admin).'
+}
+
+$oscdimg = Resolve-Tool 'oscdimg.exe' @(
+ 'C:\Program Files (x86)\Windows Kits\10\Assessment and Deployment Kit\Deployment Tools\*\Oscdimg\oscdimg.exe')
+$m = Get-Content $Manifest -Raw | ConvertFrom-Json
+$isoRoot = Join-Path $WorkDir 'iso' # writable copy of ISO contents
+$mount = Join-Path $WorkDir 'mount' # WIM mount point
+$null = New-Item -ItemType Directory -Force -Path $WorkDir,$mount,(Split-Path $OutputIso)
# --- 1. Verify input -------------------------------------------------------
function Invoke-VerifyInput {
Write-Stage 'Stage 1: verify input ISO against pinned manifest'
- $m = Get-Content $Manifest -Raw | ConvertFrom-Json
$expected = $m.baseImage.isoSha256
if ($SkipInputVerify -or $expected -like 'TODO*') {
- Write-Warning 'Input hash not pinned yet (M2). Skipping verification.'
+ $actual = (Get-FileHash -Algorithm SHA256 $SourceIso).Hash
+ Write-Warning "Input hash not pinned. Observed SHA-256: $actual (record in inputs.manifest.json)."
return
}
- $actual = (Get-FileHash -Algorithm SHA256 -Path $SourceIso).Hash
+ $actual = (Get-FileHash -Algorithm SHA256 $SourceIso).Hash
if ($actual -ne $expected) { throw "Source ISO SHA-256 mismatch.`n expected: $expected`n actual: $actual" }
Write-Host ' ISO hash verified.'
}
# --- 2. Extract ------------------------------------------------------------
function Invoke-Extract {
- Write-Stage 'Stage 2: extract ISO to work dir'
- # TODO-M2: mount $SourceIso, copy contents to $WorkDir\iso, export install.wim to $WorkDir\wim
- throw 'NotImplemented (M2): extract'
+ Write-Stage 'Stage 2: extract ISO to writable work dir'
+ if (Test-Path $isoRoot) { Remove-Item $isoRoot -Recurse -Force }
+ $null = New-Item -ItemType Directory -Force $isoRoot
+ $img = Mount-DiskImage -ImagePath $SourceIso -PassThru
+ try {
+ $drive = ($img | Get-Volume).DriveLetter + ':'
+ Write-Host " mounted at $drive ; copying..."
+ Copy-Item "$drive\*" $isoRoot -Recurse -Force
+ } finally { Dismount-DiskImage -ImagePath $SourceIso | Out-Null }
+ attrib -r "$isoRoot\*" /s /d 2>$null # clear read-only carried from the ISO
}
# --- 3. Service the WIM offline (DISM) -------------------------------------
function Invoke-ServiceWim {
- Write-Stage 'Stage 3: offline-service install.wim (drivers, updates, debloat, baseline policy, Stack staging)'
- # TODO-M2: DISM /Mount-Image; /Add-Driver (windows\drivers); /Add-Package (cumulative);
- # remove appx from windows\debloat; load offline hives + apply windows\policies baseline;
- # stage Stack + $OEM$ payload; /Unmount-Image /Commit
- throw 'NotImplemented (M2): service WIM'
+ Write-Stage 'Stage 3: offline-service install.wim'
+ $sources = Join-Path $isoRoot 'sources'
+ $wim = Join-Path $sources 'install.wim'
+ $esd = Join-Path $sources 'install.esd'
+ if (-not (Test-Path $wim) -and (Test-Path $esd)) {
+ Write-Host ' converting install.esd -> install.wim'
+ $idx = (Get-WindowsImage -ImagePath $esd | Where-Object ImageName -match 'IoT Enterprise LTSC' | Select-Object -First 1).ImageIndex
+ if (-not $idx) { $idx = 1 }
+ Export-WindowsImage -SourceImagePath $esd -SourceIndex $idx -DestinationImagePath $wim -CompressionType Max
+ Remove-Item $esd -Force
+ }
+ $idx = (Get-WindowsImage -ImagePath $wim | Where-Object ImageName -match 'IoT Enterprise LTSC' | Select-Object -First 1).ImageIndex
+ if (-not $idx) { $idx = ($m.baseImage.wimImageIndex); if (-not $idx) { $idx = 1 } }
+ Write-Host " servicing image index $idx"
+
+ Mount-WindowsImage -ImagePath $wim -Index $idx -Path $mount | Out-Null
+ try {
+ # Drivers (GPD Pocket 4 pack) -- skipped silently if dir empty (e.g. VM test).
+ $drv = Join-Path $WindowsDir 'drivers'
+ if ((Get-ChildItem $drv -Recurse -Filter *.inf -EA SilentlyContinue)) {
+ Write-Host ' adding drivers'; Add-WindowsDriver -Path $mount -Driver $drv -Recurse | Out-Null
+ } else { Write-Host ' no .inf drivers staged (ok for VM test)' }
+
+ # Debloat: remove provisioned appx listed in debloat/appx-remove.txt (best-effort).
+ $list = Join-Path $WindowsDir 'debloat\appx-remove.txt'
+ if (Test-Path $list) {
+ $prov = Get-AppxProvisionedPackage -Path $mount
+ Get-Content $list | Where-Object { $_ -and $_ -notmatch '^\s*#' } | ForEach-Object {
+ $name = $_.Trim()
+ $prov | Where-Object DisplayName -like "$name*" | ForEach-Object {
+ try { Remove-AppxProvisionedPackage -Path $mount -PackageName $_.PackageName | Out-Null; Write-Host " removed $($_.DisplayName)" } catch {}
+ }
+ }
+ }
+
+ # Stage the hardening payload: SetupComplete.cmd (native auto-run as SYSTEM) + modules.
+ $scripts = Join-Path $mount 'Windows\Setup\Scripts'
+ $null = New-Item -ItemType Directory -Force $scripts, (Join-Path $scripts 'hardening')
+ Copy-Item (Join-Path $PSScriptRoot 'oem\SetupComplete.cmd') $scripts -Force
+ Copy-Item (Join-Path $WindowsDir 'hardening\*') (Join-Path $scripts 'hardening') -Recurse -Force
+ } finally {
+ Dismount-WindowsImage -Path $mount -Save | Out-Null
+ }
}
-# --- 4. Inject answer file + first-boot payload ----------------------------
+# --- 4. Inject answer file -------------------------------------------------
function Invoke-InjectUnattend {
- Write-Stage 'Stage 4: inject autounattend.xml + $OEM$\SetupComplete.cmd + hardening modules'
- # TODO-M2: copy autounattend\autounattend.xml to ISO root; lay $OEM$\SetupComplete.cmd
- # + windows\hardening\* into the image so first boot can run them
- throw 'NotImplemented (M2): inject unattend'
+ Write-Stage 'Stage 4: inject autounattend.xml at ISO root'
+ Copy-Item (Join-Path $PSScriptRoot 'autounattend\autounattend.xml') (Join-Path $isoRoot 'autounattend.xml') -Force
}
# --- 5. Brand --------------------------------------------------------------
-function Invoke-Brand {
- Write-Stage 'Stage 5: apply branding (boot/OOBE wallpaper, computer-name pattern)'
- # TODO-M4: from ..\..\shared\branding
- Write-Warning ' Branding deferred to M4.'
-}
+function Invoke-Brand { Write-Stage 'Stage 5: branding'; Write-Warning ' deferred to M4.' }
# --- 6. Repack -------------------------------------------------------------
function Invoke-Repack {
- Write-Stage 'Stage 6: repack UEFI-bootable ISO via oscdimg'
- # TODO-M2: oscdimg -m -o -u2 -udfver102 -bootdata:2#... -> $OutputIso
- throw 'NotImplemented (M2): repack'
+ Write-Stage 'Stage 6: repack UEFI-bootable ISO (oscdimg)'
+ $etfs = Join-Path $isoRoot 'boot\etfsboot.com'
+ $efi = Join-Path $isoRoot 'efi\microsoft\boot\efisys.bin'
+ if (-not (Test-Path $efi)) { throw "missing UEFI boot image: $efi" }
+ $bootdata = '2#p0,e,b"{0}"#pEF,e,b"{1}"' -f $etfs, $efi
+ if (Test-Path $OutputIso) { Remove-Item $OutputIso -Force }
+ & $oscdimg -m -o -u2 -udfver102 -l"SILVERMETAL" "-bootdata:$bootdata" $isoRoot $OutputIso
+ if ($LASTEXITCODE -ne 0) { throw "oscdimg failed ($LASTEXITCODE)" }
}
# --- 7. Attest -------------------------------------------------------------
function Invoke-Attest {
- Write-Stage 'Stage 7: emit SHA-256 + SBOM + signed build attestation'
- # TODO-M3: hash $OutputIso; build SBOM from manifest + tool versions; sign (trust-model.md)
- throw 'NotImplemented (M3): attest'
+ Write-Stage 'Stage 7: SHA-256 + SBOM'
+ $hash = (Get-FileHash -Algorithm SHA256 $OutputIso).Hash
+ "$hash *$(Split-Path $OutputIso -Leaf)" | Set-Content "$OutputIso.sha256"
+ $sbom = [ordered]@{
+ product = $m.product
+ builtFrom = @{ sourceIsoSha256 = (Get-FileHash -Algorithm SHA256 $SourceIso).Hash; manifest = $m.baseImage }
+ output = @{ iso = (Split-Path $OutputIso -Leaf); sha256 = $hash }
+ tooling = @{ oscdimg = $oscdimg; dism = (Get-Command dism.exe).Version.ToString() }
+ note = 'Reproducibility: pinned inputs + SBOM + SHA, NOT bit-identical (iso-builder.md §5). Signing: TODO-M3.'
+ }
+ $sbom | ConvertTo-Json -Depth 6 | Set-Content "$OutputIso.sbom.json"
+ Write-Host " ISO SHA-256: $hash"
}
# --- orchestrate -----------------------------------------------------------
diff --git a/windows/tests/Assert-IsoStructure.ps1 b/windows/tests/Assert-IsoStructure.ps1
new file mode 100644
index 0000000..e958978
--- /dev/null
+++ b/windows/tests/Assert-IsoStructure.ps1
@@ -0,0 +1,44 @@
+#Requires -Version 5.1
+<# SilverMetal Enhanced - Windows | Offline ISO structure assertions.
+ The reliable CI gate that needs NO nested virtualization: proves the built
+ ISO baked the answer file + hardening payload correctly. Full boot-and-Verify
+ (QEMU + OVMF + swtpm) is a follow-on stage that needs nested virt.
+ Exit 0 = all assertions pass; non-zero = failures.
+#>
+[CmdletBinding()] param([Parameter(Mandatory)][string]$IsoPath)
+Set-StrictMode -Version Latest; $ErrorActionPreference = 'Stop'
+
+$fail = 0
+function Assert { param([string]$Name,[bool]$Cond)
+ if ($Cond) { Write-Host "[PASS] $Name" -ForegroundColor Green }
+ else { Write-Host "[FAIL] $Name" -ForegroundColor Red; $script:fail++ }
+}
+
+if (-not (([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
+ [Security.Principal.WindowsBuiltInRole]::Administrator))) { throw 'must run elevated (WIM mount).' }
+
+$img = Mount-DiskImage -ImagePath $IsoPath -PassThru
+$mount = Join-Path $env:TEMP 'sm-assert-wim'
+$null = New-Item -ItemType Directory -Force $mount
+try {
+ $drive = ($img | Get-Volume).DriveLetter + ':'
+ Assert 'autounattend.xml at ISO root' (Test-Path "$drive\autounattend.xml")
+ $wim = "$drive\sources\install.wim"
+ Assert 'sources\install.wim present' (Test-Path $wim)
+
+ if (Test-Path $wim) {
+ $idx = (Get-WindowsImage -ImagePath $wim | Where-Object ImageName -match 'IoT Enterprise LTSC' | Select-Object -First 1).ImageIndex
+ if (-not $idx) { $idx = 1 }
+ Mount-WindowsImage -ImagePath $wim -Index $idx -Path $mount -ReadOnly | Out-Null
+ try {
+ $sc = Join-Path $mount 'Windows\Setup\Scripts\SetupComplete.cmd'
+ Assert 'SetupComplete.cmd baked into WIM' (Test-Path $sc)
+ $mods = Get-ChildItem (Join-Path $mount 'Windows\Setup\Scripts\hardening') -Filter *.ps1 -EA SilentlyContinue
+ Assert 'hardening modules baked (>=9 .ps1)' ($mods.Count -ge 9)
+ Assert 'Verify script baked' (Test-Path (Join-Path $mount 'Windows\Setup\Scripts\hardening\Verify-SilverMetalWindows.ps1'))
+ } finally { Dismount-WindowsImage -Path $mount -Discard | Out-Null }
+ }
+} finally { Dismount-DiskImage -ImagePath $IsoPath | Out-Null }
+
+Write-Host "`n$($fail) assertion(s) failed."
+exit $fail