diff --git a/.github/workflows/windows-msi-sign.yml.example b/.github/workflows/windows-msi-sign.yml.example new file mode 100644 index 0000000..566d37d --- /dev/null +++ b/.github/workflows/windows-msi-sign.yml.example @@ -0,0 +1,164 @@ +# EXAMPLE ONLY — not a live workflow. +# Copy into .github/workflows/windows-msi-sign.yml (or .forgejo/workflows/) and enable +# only when org secret store holds the names below. Default product CI remains +# windows-msi.yml (unsigned full MSI). +# +# Secret / var NAMES only — never commit PFX bytes, passwords, thumbprints as values, +# or Azure client secrets. See platform/windows/wix/CI-SIGN-NOTARY.md. +# +# GitHub Actions: secrets.* / vars.* as below. +# Forgejo Actions: same secret names; map runs-on to your Windows label; keep names identical. +# +# After sign + upload: docs/PUBLISH-DIGESTS.md + fill-manifest-sha256.ps1 (hash only; no upload). +# +# name: windows-msi-sign +# on: +# workflow_dispatch: +# # push: +# # tags: ["printstamp-v*"] + +name: windows-msi-sign-example + +on: + workflow_dispatch: + +permissions: + contents: read + # id-token: write # uncomment if using Azure OIDC → Trusted Signing (no client secret) + +jobs: + # Live default stays unsigned — see windows-msi.yml. + # This example job is gated off until secrets exist. + full-msi-sign: + if: ${{ false }} # flip only after secrets exist; do not claim SmartScreen until clean-VM proof + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Install WiX Toolset v3 + shell: pwsh + run: | + choco install wixtoolset --version 3.14.1 -y --no-progress + $wixBin = "${env:ProgramFiles(x86)}\WiX Toolset v3.14\bin" + if (-not (Test-Path -LiteralPath (Join-Path $wixBin "candle.exe"))) { + $found = Get-ChildItem -Path "${env:ProgramFiles(x86)}" -Filter "candle.exe" -Recurse -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match 'WiX Toolset' } | + Select-Object -First 1 + if ($found) { $wixBin = $found.Directory.FullName } + } + if (-not (Test-Path -LiteralPath (Join-Path $wixBin "candle.exe"))) { + Write-Error "WiX candle.exe not found after install." + } + "WIX_BIN=$wixBin" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + "PATH=$wixBin;$env:PATH" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + + - name: Build full MSI (pre-sign) + shell: pwsh + run: | + powershell -NoProfile -ExecutionPolicy Bypass -File platform\windows\scripts\build-full-msi.ps1 -Arch amd64 + $msi = Get-ChildItem -Path platform\windows\wix\out -Filter "VeraePrintStamp-windows-*.msi" | + Sort-Object LastWriteTime -Descending | Select-Object -First 1 + if (-not $msi) { Write-Error "No MSI under platform/windows/wix/out" } + "MSI_PATH=$($msi.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + "MSI_NAME=$($msi.Name)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + $stage = "platform\windows\wix\StageFull\bin" + "STAGE_BIN=$stage" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + + # --- Path A: PFX via secrets (signtool) --- + # Secret / var names (placeholders — values never in git): + # secrets.WINDOWS_CERT_PFX_BASE64 + # secrets.WINDOWS_CERT_PFX_PASSWORD + # secrets.WINDOWS_CERT_THUMBPRINT # optional if /sha1 preferred after import + # vars.WINDOWS_SIGN_TIMESTAMP_URL # RFC 3161 TSA + # + # - name: Import code-signing PFX + # shell: pwsh + # env: + # WINDOWS_CERT_PFX_BASE64: ${{ secrets.WINDOWS_CERT_PFX_BASE64 }} + # WINDOWS_CERT_PFX_PASSWORD: ${{ secrets.WINDOWS_CERT_PFX_PASSWORD }} + # run: | + # $pfx = Join-Path $env:RUNNER_TEMP "codesign.pfx" + # [IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String($env:WINDOWS_CERT_PFX_BASE64)) + # $pwd = ConvertTo-SecureString $env:WINDOWS_CERT_PFX_PASSWORD -AsPlainText -Force + # Import-PfxCertificate -FilePath $pfx -CertStoreLocation Cert:\CurrentUser\My -Password $pwd | Out-Null + # Remove-Item -LiteralPath $pfx -Force -ErrorAction SilentlyContinue + # # Never Upload-Artifact the PFX; never Write-Host password or base64. + # + # - name: signtool Authenticode (EXEs + MSI) + # shell: pwsh + # env: + # WINDOWS_SIGN_TIMESTAMP_URL: ${{ vars.WINDOWS_SIGN_TIMESTAMP_URL }} + # WINDOWS_CERT_THUMBPRINT: ${{ secrets.WINDOWS_CERT_THUMBPRINT }} + # run: | + # $tsa = $env:WINDOWS_SIGN_TIMESTAMP_URL + # if (-not $tsa) { $tsa = "https://timestamp.digicert.com" } + # $signtool = (Get-Command signtool.exe -ErrorAction Stop).Source + # $targets = @( + # (Join-Path $env:STAGE_BIN "verae-print.exe"), + # (Join-Path $env:STAGE_BIN "verae-printhelper.exe"), + # $env:MSI_PATH + # ) | Where-Object { $_ -and (Test-Path -LiteralPath $_) } + # foreach ($t in $targets) { + # if ($env:WINDOWS_CERT_THUMBPRINT) { + # & $signtool sign /fd SHA256 /tr $tsa /td SHA256 /sha1 $env:WINDOWS_CERT_THUMBPRINT $t + # } else { + # & $signtool sign /fd SHA256 /tr $tsa /td SHA256 /a $t + # } + # if ($LASTEXITCODE -ne 0) { Write-Error "signtool sign failed: $t" } + # & $signtool verify /pa $t + # if ($LASTEXITCODE -ne 0) { Write-Error "signtool verify failed: $t" } + # } + + # --- Path B: Azure Trusted Signing (prefer over PFX when available) --- + # Secret / var names (placeholders — values never in git): + # secrets.WINDOWS_TRUSTED_SIGNING_TENANT_ID # or OIDC; skip client secret + # secrets.WINDOWS_TRUSTED_SIGNING_CLIENT_ID + # secrets.WINDOWS_TRUSTED_SIGNING_CLIENT_SECRET # omit when id-token: write + federated creds + # vars.WINDOWS_TRUSTED_SIGNING_ENDPOINT # e.g. https://cus.codesigning.azure.net + # vars.WINDOWS_TRUSTED_SIGNING_ACCOUNT + # vars.WINDOWS_TRUSTED_SIGNING_CERTIFICATE_PROFILE + # vars.WINDOWS_SIGN_TIMESTAMP_URL # optional; action may default TSA + # + # - name: Azure Trusted Signing (EXEs + MSI) + # # uses: azure/trusted-signing-action@v0 # pin a reviewed tag when enabling + # # with: + # # azure-tenant-id: ${{ secrets.WINDOWS_TRUSTED_SIGNING_TENANT_ID }} + # # azure-client-id: ${{ secrets.WINDOWS_TRUSTED_SIGNING_CLIENT_ID }} + # # azure-client-secret: ${{ secrets.WINDOWS_TRUSTED_SIGNING_CLIENT_SECRET }} + # # endpoint: ${{ vars.WINDOWS_TRUSTED_SIGNING_ENDPOINT }} + # # trusted-signing-account-name: ${{ vars.WINDOWS_TRUSTED_SIGNING_ACCOUNT }} + # # certificate-profile-name: ${{ vars.WINDOWS_TRUSTED_SIGNING_CERTIFICATE_PROFILE }} + # # files-folder: ${{ env.STAGE_BIN }} + # # files-folder-filter: exe + # # files-folder-recurse: true + # # file-digest: SHA256 + # # timestamp-rfc3161: ${{ vars.WINDOWS_SIGN_TIMESTAMP_URL }} + # # timestamp-digest: SHA256 + # shell: pwsh + # run: | + # Write-Error "Trusted Signing path not enabled — wire action + secret names above." + + # - name: Upload signed MSI artifact + # uses: actions/upload-artifact@v4 + # with: + # name: VeraePrintStamp-windows-msi-signed + # path: platform/windows/wix/out/VeraePrintStamp-windows-*.msi + # if-no-files-found: error + # retention-days: 14 + + # Operator after download: upload CDN blob → fill-manifest-sha256.ps1 → paste digests. + # See docs/PUBLISH-DIGESTS.md (does not upload; secret values never land in manifests). + + - name: Placeholder (example not enabled) + shell: pwsh + run: | + Write-Host "windows-msi-sign.yml.example — sign job gated if: false." + Write-Host "Live unsigned build: .github/workflows/windows-msi.yml" + Write-Host "Local dry-run: platform/windows/scripts/sign-dry-run.ps1" + Write-Host "Digests after sign+upload: docs/PUBLISH-DIGESTS.md" diff --git a/.github/workflows/windows-msi.yml b/.github/workflows/windows-msi.yml index 4532010..b5d70a3 100644 --- a/.github/workflows/windows-msi.yml +++ b/.github/workflows/windows-msi.yml @@ -79,22 +79,81 @@ jobs: # --- Future Authenticode (NOT enabled) --- # Wire only after certs exist in the org secret store. Never commit PFX or passwords. + # Full copy-paste example (gated if: false): .github/workflows/windows-msi-sign.yml.example + # Local dry-run (no secrets): platform/windows/scripts/sign-dry-run.ps1 + # After sign + CDN upload: docs/PUBLISH-DIGESTS.md + fill-manifest-sha256.ps1 # Secret / var names (placeholders only — see CI-SIGN-NOTARY.md): # secrets.WINDOWS_CERT_PFX_BASE64 # secrets.WINDOWS_CERT_PFX_PASSWORD - # secrets.WINDOWS_CERT_THUMBPRINT # optional if cert pre-installed - # vars.WINDOWS_SIGN_TIMESTAMP_URL # RFC 3161 TSA - # secrets.WINDOWS_TRUSTED_SIGNING_* # optional Azure Trusted Signing instead of PFX - # Steps (when live): import PFX → signtool sign EXE + MSI → never upload PFX as artifact. - # - name: Sign MSI + EXEs (Authenticode) + # secrets.WINDOWS_CERT_THUMBPRINT # optional /sha1 after import + # vars.WINDOWS_SIGN_TIMESTAMP_URL # RFC 3161 TSA + # secrets.WINDOWS_TRUSTED_SIGNING_TENANT_ID # Path B: Azure Trusted Signing + # secrets.WINDOWS_TRUSTED_SIGNING_CLIENT_ID + # secrets.WINDOWS_TRUSTED_SIGNING_CLIENT_SECRET # omit when OIDC id-token: write + # vars.WINDOWS_TRUSTED_SIGNING_ENDPOINT + # vars.WINDOWS_TRUSTED_SIGNING_ACCOUNT + # vars.WINDOWS_TRUSTED_SIGNING_CERTIFICATE_PROFILE + # + # --- Path A: PFX → signtool (commented; secret NAMES only) --- + # - name: Import code-signing PFX # if: ${{ false }} # shell: pwsh # env: # WINDOWS_CERT_PFX_BASE64: ${{ secrets.WINDOWS_CERT_PFX_BASE64 }} # WINDOWS_CERT_PFX_PASSWORD: ${{ secrets.WINDOWS_CERT_PFX_PASSWORD }} - # WINDOWS_SIGN_TIMESTAMP_URL: ${{ vars.WINDOWS_SIGN_TIMESTAMP_URL }} # run: | - # Write-Error "Sign step not enabled — unsigned artifact only." + # $pfx = Join-Path $env:RUNNER_TEMP "codesign.pfx" + # [IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String($env:WINDOWS_CERT_PFX_BASE64)) + # $pwd = ConvertTo-SecureString $env:WINDOWS_CERT_PFX_PASSWORD -AsPlainText -Force + # Import-PfxCertificate -FilePath $pfx -CertStoreLocation Cert:\CurrentUser\My -Password $pwd | Out-Null + # Remove-Item -LiteralPath $pfx -Force -ErrorAction SilentlyContinue + # # Never Upload-Artifact the PFX; never echo password or base64. + # + # - name: signtool Authenticode (EXEs + MSI) + # if: ${{ false }} + # shell: pwsh + # env: + # WINDOWS_SIGN_TIMESTAMP_URL: ${{ vars.WINDOWS_SIGN_TIMESTAMP_URL }} + # WINDOWS_CERT_THUMBPRINT: ${{ secrets.WINDOWS_CERT_THUMBPRINT }} + # run: | + # $tsa = $env:WINDOWS_SIGN_TIMESTAMP_URL + # if (-not $tsa) { $tsa = "https://timestamp.digicert.com" } + # $signtool = (Get-Command signtool.exe -ErrorAction Stop).Source + # $stage = "platform\windows\wix\StageFull\bin" + # $targets = @( + # (Join-Path $stage "verae-print.exe"), + # (Join-Path $stage "verae-printhelper.exe"), + # $env:MSI_PATH + # ) | Where-Object { $_ -and (Test-Path -LiteralPath $_) } + # foreach ($t in $targets) { + # if ($env:WINDOWS_CERT_THUMBPRINT) { + # & $signtool sign /fd SHA256 /tr $tsa /td SHA256 /sha1 $env:WINDOWS_CERT_THUMBPRINT $t + # } else { + # & $signtool sign /fd SHA256 /tr $tsa /td SHA256 /a $t + # } + # if ($LASTEXITCODE -ne 0) { Write-Error "signtool sign failed: $t" } + # & $signtool verify /pa $t + # if ($LASTEXITCODE -ne 0) { Write-Error "signtool verify failed: $t" } + # } + # + # --- Path B: Azure Trusted Signing (prefer over PFX when available) --- + # - name: Azure Trusted Signing (EXEs + MSI) + # if: ${{ false }} + # # uses: azure/trusted-signing-action@v0 # pin a reviewed tag when enabling + # # with: + # # azure-tenant-id: ${{ secrets.WINDOWS_TRUSTED_SIGNING_TENANT_ID }} + # # azure-client-id: ${{ secrets.WINDOWS_TRUSTED_SIGNING_CLIENT_ID }} + # # azure-client-secret: ${{ secrets.WINDOWS_TRUSTED_SIGNING_CLIENT_SECRET }} + # # endpoint: ${{ vars.WINDOWS_TRUSTED_SIGNING_ENDPOINT }} + # # trusted-signing-account-name: ${{ vars.WINDOWS_TRUSTED_SIGNING_ACCOUNT }} + # # certificate-profile-name: ${{ vars.WINDOWS_TRUSTED_SIGNING_CERTIFICATE_PROFILE }} + # # files: ${{ env.MSI_PATH }} + # # file-digest: SHA256 + # # timestamp-rfc3161: ${{ vars.WINDOWS_SIGN_TIMESTAMP_URL }} + # # timestamp-digest: SHA256 + # shell: pwsh + # run: | + # Write-Error "Trusted Signing not enabled — unsigned artifact only. See windows-msi-sign.yml.example." - name: Upload unsigned MSI artifact uses: actions/upload-artifact@v4 diff --git a/docs/COMMERCIAL-INSTALLER-BACKLOG.md b/docs/COMMERCIAL-INSTALLER-BACKLOG.md index c710197..f4f5c07 100644 --- a/docs/COMMERCIAL-INSTALLER-BACKLOG.md +++ b/docs/COMMERCIAL-INSTALLER-BACKLOG.md @@ -19,24 +19,27 @@ Repo now includes **authoring/docs scaffolds** for the two commercial gaps that | **Menubar CI notary sketch** | [`platform/macos/menubar/ci-notarize.yml.example`](../platform/macos/menubar/ci-notarize.yml.example) | GHA/Forgejo **example** workflow: default **unsigned** `build-menubar-app.sh` + artifact; commented notarytool job gated `if: false`; secret **names** only (`APPLE_API_KEY_*`, `APPLE_CODESIGN_IDENTITY`, …). | Not a live workflow under `.github/`; no staple; not Gatekeeper-proven. | | **Menubar notary dry-run** | [`platform/macos/menubar/notary-dry-run.sh`](../platform/macos/menubar/notary-dry-run.sh) | Local helper: if `notarytool` present print codesign/`ditto`/submit/staple recipe with env **names** only; else Xcode CLT tip (exit 0). Links [`ci-notarize.yml.example`](../platform/macos/menubar/ci-notarize.yml.example) + this backlog. **Never submits** unless `NOTARY_SUBMIT=1`. | Not live CI; does not codesign by default; not Gatekeeper-proven. | | **Full elevated MSI (StageFull + light)** | [`Product-Full.wxs`](../platform/windows/wix/Product-Full.wxs) + [`scripts/build-full-msi.ps1`](../platform/windows/scripts/build-full-msi.ps1) (+ outline, [`SILENT_PROPS.md`](../platform/windows/wix/SILENT_PROPS.md), [`CI-SIGN-NOTARY.md`](../platform/windows/wix/CI-SIGN-NOTARY.md), [`DUAL-SHIP.md`](../platform/windows/wix/DUAL-SHIP.md)) | Elevated WiX v3 product: perMachine, Feature/ComponentGroups for **bin** (`verae-print.exe` required), **scripts**, **docs**, Start Menu; silent public props; distinct UpgradeCode. `build-full-msi.ps1` creates `StageFull/{bin,scripts}`, copies or Go-builds EXEs, copies scripts/LICENSE/README, stamps **ProductVersion** from `Get-VeraePackageVersion`, runs candle/light → `VeraePrintStamp-windows-*-.msi`. WiX missing → **exit 1** + clear tip. Version defaults on build-windows / build-pkg / NSIS / build-installer aligned to HelperVersion. Commented deferred CA + `InstallExecuteSequence` stubs for `install-printer.ps1` (no binaries required). Dual-ship runbook documents NSIS vs MSI clean-VM gates + **thin-NSIS exit checklist**. | **Not** Authenticode-signed; printer CAs still **commented** (not scheduled/tested); properties not yet honored by live CAs; not Intune-proven; NSIS still primary full install; CI builds **unsigned** MSI only (sign step not live). | -| **CI unsigned full MSI** | [`.github/workflows/windows-msi.yml`](../.github/workflows/windows-msi.yml) + DUAL-SHIP § CI artifact | `windows-latest`: install WiX v3 → `build-full-msi.ps1` → upload artifact `VeraePrintStamp-windows-msi-unsigned`. Future Authenticode secret **names** only in workflow comments / CI-SIGN-NOTARY. | No live `signtool`; no certs in git; not a production download channel. | +| **CI unsigned full MSI** | [`.github/workflows/windows-msi.yml`](../.github/workflows/windows-msi.yml) + DUAL-SHIP § CI artifact | `windows-latest`: install WiX v3 → `build-full-msi.ps1` → upload artifact `VeraePrintStamp-windows-msi-unsigned`. Commented **signtool** + **Trusted Signing** steps (secret **names** only). | No live `signtool`; no certs in git; not a production download channel. | +| **CI sign example** | [`.github/workflows/windows-msi-sign.yml.example`](../.github/workflows/windows-msi-sign.yml.example) | GHA/Forgejo **example**: build + Path A PFX/`signtool` + Path B Azure Trusted Signing; all gated `if: false`; secret **names** only. | Not a live workflow; no certs; does not claim SmartScreen. | +| **Publish digests runbook** | [`docs/PUBLISH-DIGESTS.md`](PUBLISH-DIGESTS.md) | Operator order: build → sign → upload → `fill-manifest-sha256.ps1` → paste TBD. | Does not upload or enable live sign. | ### Issue 1 — what landed vs still open | Done (advanced) | Still left (Issue 1 commercial) | |-----------------|----------------------------------| | `Product-Full.wxs` File components match StageFull; candle/light-ready | **Enable** (`enable-printer-cas.ps1`) **+ test** printer deferred CAs on clean VM (DUAL-SHIP VM C); parity with NSIS | -| `build-full-msi.ps1` StageFull + ProductVersion + exit codes | Live CI: **signtool / Trusted Signing** on MSI + EXEs (workflow sign step commented; secret names only) | -| `CI-SIGN-NOTARY.md` GHA/Forgejo draft (secret **names** only) + [`sign-dry-run.ps1`](../platform/windows/scripts/sign-dry-run.ps1) local verify/print | Live CI **signtool** step still commented; live notarytool staple on macOS; clean-VM Gatekeeper proof | +| `build-full-msi.ps1` StageFull + ProductVersion + exit codes | **Enable** live CI Authenticode: wire org secrets + flip `windows-msi-sign.yml.example` / uncomment `windows-msi.yml` sign steps | +| `CI-SIGN-NOTARY.md` GHA/Forgejo draft (secret **names** only) + [`sign-dry-run.ps1`](../platform/windows/scripts/sign-dry-run.ps1) local verify/print | Live CI **signtool** / Trusted Signing still **not** running; live notarytool staple on macOS; clean-VM Gatekeeper proof | | `SILENT_PROPS.md` msiexec one-liner + exit codes for full MSI | Properties honored by **scheduled** CAs (config write, skip printers/key) | | HelperVersion path on build-windows / build-pkg / NSIS / build-installer / full MSI | Dual-ship **clean-VM validation** (A/B/C); then execute thin-NSIS exit checklist | | Commented CA + `InstallExecuteSequence` stubs (`install-printer` schedule) | Intune/SCCM silent smoke with real exit codes; publish 0.3.6 signed blobs + manifest checksums | | [`DUAL-SHIP.md`](../platform/windows/wix/DUAL-SHIP.md) NSIS+MSI + CI artifact + **thin-NSIS exit checklist** | Site primary download still NSIS; thin/demote NSIS only after exit checklist A–D | | Site product string → **0.3.6**; manifest header notes HelperVersion vs last-published 0.3.2 | Do **not** claim Authenticode / notary / dual-ship / thin-NSIS complete | -| [`.github/workflows/windows-msi.yml`](../.github/workflows/windows-msi.yml) unsigned MSI on `windows-latest` + artifact upload | Wire org secrets and enable sign step; publish signed assets to site | +| [`.github/workflows/windows-msi.yml`](../.github/workflows/windows-msi.yml) unsigned MSI + **commented** signtool / Trusted Signing (secret names) | Org secrets + enable sign; publish signed assets to site | +| [`windows-msi-sign.yml.example`](../.github/workflows/windows-msi-sign.yml.example) full sign sketch (`if: false`) | Promote example → live workflow when certs exist | | [`ci-notarize.yml.example`](../platform/macos/menubar/ci-notarize.yml.example) menubar GHA/Forgejo sketch (unsigned default; secret names) | Promote example → live workflow; automate `build-menubar-app.sh` sign when `SIGNING_ENV` set | | **0.3.6 Windows NSIS+MSI placeholder rows** ([`DOWNLOADS-MANIFEST.txt`](../site/printit.verae.ai/DOWNLOADS-MANIFEST.txt) + [`DOWNLOADS-MANIFEST.md`](../site/printit.verae.ai/DOWNLOADS-MANIFEST.md); sha256 **TBD**) | Build/upload signed 0.3.6 blobs; replace TBD with real digests | -| [`fill-manifest-sha256.ps1`](../platform/windows/scripts/fill-manifest-sha256.ps1) local hash → DOWNLOADS-MANIFEST rows (linked from DUAL-SHIP + manifest.md; **no upload**) | Operator still must upload first, then paste digests over TBD | +| [`fill-manifest-sha256.ps1`](../platform/windows/scripts/fill-manifest-sha256.ps1) + [`PUBLISH-DIGESTS.md`](PUBLISH-DIGESTS.md) (hash → paste; **no upload**) | Operator still must upload first, then paste digests over TBD | | **Printable VM A/B/C sheet** [`VM-DUAL-SHIP-CHECKLIST.md`](../platform/windows/wix/VM-DUAL-SHIP-CHECKLIST.md) | Operator sign-off on clean VMs (C blocked until CAs uncommented) | | **Offline dual-ship smoke** [`dual-ship-smoke.ps1`](../platform/windows/scripts/dual-ship-smoke.ps1) (prints A/B/C; **VM C blocked until CAs enabled**; `-VerifyArtifacts` → existence + `fill-manifest-sha256.ps1`) | Does **not** replace clean-VM sign-off; no install/sign/upload | | **Enable printer CAs** [`enable-printer-cas.ps1`](../platform/windows/scripts/enable-printer-cas.ps1) + wix README § Enable printer CAs | Unwraps `DEFERRED_PRINTER_CAS:OFF` in Product-Full for VM C; does **not** build/test/sign; default remains disabled | @@ -103,7 +106,7 @@ NSIS product already covers much of the *install experience* (files under Progra | **Custom actions parity with NSIS** | Printers, scheduled helper, per-user key | Port `install-printer.ps1`, helper registration into deferred/impersonated CAs (stubs commented in Product-Full) | | **Per-user vs per-machine story** | Tray is user-level today; MSI is typically machine | Machine full MSI + optional per-user tray MSI (both authored; product decision open) | | **Version drift in build scripts** | **Largely fixed** — HelperVersion path on build-windows, build-full-msi, build-pkg, build-installer, NSIS fallbacks **0.3.6**; site index product string **0.3.6** | Keep smoke failing on new hard-coded older defaults; `DOWNLOADS-MANIFEST` still checksums **last-published 0.3.2** blobs until release upload | -| **CI job that builds + signs MSI** | Reproducible release | **Unsigned build live** (`.github/workflows/windows-msi.yml`); signtool / Trusted Signing still **not live** (secret names only) | +| **CI job that builds + signs MSI** | Reproducible release | **Unsigned build live** (`windows-msi.yml`); sign **example** + commented Path A/B (`windows-msi-sign.yml.example`); signtool / Trusted Signing still **not live** | | **MSIX / Print Support App** (later) | Modern print path in original spec | Explicitly **deferred** unless enterprise asks; not blocking first MSI | ### Windows backlog order (suggested) @@ -112,9 +115,9 @@ NSIS product already covers much of the *install experience* (files under Progra 2. ~~Dual-ship runbook + commented printer CA schedule stubs~~ (**done**: [`DUAL-SHIP.md`](../platform/windows/wix/DUAL-SHIP.md); Product-Full commented `InstallExecuteSequence`). 3. ~~CI unsigned full MSI workflow + artifact~~ (**done**: [`.github/workflows/windows-msi.yml`](../.github/workflows/windows-msi.yml); DUAL-SHIP § CI artifact). 4. **Enable** printer/helper deferred CAs (`enable-printer-cas.ps1`) + test on clean VM (DUAL-SHIP VM C); keep NSIS primary until parity. `dual-ship-smoke` blocks C until CAs enabled. -5. Authenticode on binaries + MSI (**not shipped** — enable commented sign step; secret names in workflow + `CI-SIGN-NOTARY.md`; local dry-run [`sign-dry-run.ps1`](../platform/windows/scripts/sign-dry-run.ps1)). +5. Authenticode on binaries + MSI (**not shipped** — enable [`windows-msi-sign.yml.example`](../.github/workflows/windows-msi-sign.yml.example) / commented steps in `windows-msi.yml`; secret names in `CI-SIGN-NOTARY.md`; local dry-run [`sign-dry-run.ps1`](../platform/windows/scripts/sign-dry-run.ps1)). 6. Honor silent props in live CAs + Intune sample command line proof. -7. Publish 0.3.6 artifacts; refresh site `DOWNLOADS-MANIFEST` checksums via `fill-manifest-sha256.ps1` (hash-only; no upload); keep smoke on HelperVersion drift. +7. Publish 0.3.6 artifacts; refresh site `DOWNLOADS-MANIFEST` per [`PUBLISH-DIGESTS.md`](PUBLISH-DIGESTS.md) + `fill-manifest-sha256.ps1` (hash-only; no upload); keep smoke on HelperVersion drift. 8. Deprecate or thin NSIS once MSI is the primary download (**thin-NSIS exit checklist** A–D in DUAL-SHIP.md — all boxes). --- @@ -189,7 +192,8 @@ NSIS product already covers much of the *install experience* (files under Progra | Dual-ship NSIS↔MSI runbook | **Yes** ([`DUAL-SHIP.md`](../platform/windows/wix/DUAL-SHIP.md)) | **Docs only** — clean-VM validation + thin-NSIS exit checklist still unchecked | | Menubar CI notary example | **Yes** ([`ci-notarize.yml.example`](../platform/macos/menubar/ci-notarize.yml.example)) | **Example only** — unsigned default; notary job `if: false` | | Menubar notary dry-run | **Yes** ([`notary-dry-run.sh`](../platform/macos/menubar/notary-dry-run.sh)) | **Scaffold** — print recipe / tip; submit gated `NOTARY_SUBMIT=1` only | -| Windows Authenticode pipeline | **Draft doc** (`wix/CI-SIGN-NOTARY.md`) + **dry-run** [`sign-dry-run.ps1`](../platform/windows/scripts/sign-dry-run.ps1) | **No** — no live signtool job (**do not claim shipped**) | +| Windows Authenticode pipeline | **Draft** (`wix/CI-SIGN-NOTARY.md`) + **dry-run** [`sign-dry-run.ps1`](../platform/windows/scripts/sign-dry-run.ps1) + commented steps in [`windows-msi.yml`](../.github/workflows/windows-msi.yml) + [`windows-msi-sign.yml.example`](../.github/workflows/windows-msi-sign.yml.example) | **No** — no live signtool job (**do not claim shipped**) | +| Publish digests runbook | **Yes** ([`PUBLISH-DIGESTS.md`](PUBLISH-DIGESTS.md) + `fill-manifest-sha256.ps1`) | **Docs/tool** — operator still uploads + pastes TBD | | macOS menu bar LaunchAgent | Yes | Yes (dev/ops) | | macOS unsigned menubar `.app` | Yes | **No** (Gatekeeper) | | macOS **signed+notarized `.pkg`** script | Yes | **Near** — needs version sync, CI, clean-VM proof | @@ -207,6 +211,8 @@ NSIS product already covers much of the *install experience* (files under Progra - [`platform/windows/wix/Product-Full.wxs`](../platform/windows/wix/Product-Full.wxs) + [`build-full-msi.ps1`](../platform/windows/scripts/build-full-msi.ps1) — elevated full MSI StageFull + candle/light - [`platform/windows/wix/DUAL-SHIP.md`](../platform/windows/wix/DUAL-SHIP.md) — NSIS + MSI side-by-side validation runbook - [`platform/windows/wix/CI-SIGN-NOTARY.md`](../platform/windows/wix/CI-SIGN-NOTARY.md) — CI Authenticode + notarytool sketch (**draft only**) +- [`.github/workflows/windows-msi-sign.yml.example`](../.github/workflows/windows-msi-sign.yml.example) — GHA sign example (signtool + Trusted Signing; `if: false`; secret names only) +- [`docs/PUBLISH-DIGESTS.md`](PUBLISH-DIGESTS.md) — fill DOWNLOADS-MANIFEST after sign via `fill-manifest-sha256.ps1` - [`platform/windows/scripts/sign-dry-run.ps1`](../platform/windows/scripts/sign-dry-run.ps1) — local `signtool` verify + print sign commands (no secrets; exit 0 if no signtool) - [`platform/windows/wix/SILENT_PROPS.md`](../platform/windows/wix/SILENT_PROPS.md) — msiexec `/qn` property contract - [`platform/macos/pkg/`](../platform/macos/pkg/) — commercial `.pkg` build diff --git a/docs/PUBLISH-DIGESTS.md b/docs/PUBLISH-DIGESTS.md new file mode 100644 index 0000000..4cbc78a --- /dev/null +++ b/docs/PUBLISH-DIGESTS.md @@ -0,0 +1,87 @@ +# Publish digests — DOWNLOADS-MANIFEST after sign + +Operator runbook for filling [`site/printit.verae.ai/DOWNLOADS-MANIFEST.txt`](../site/printit.verae.ai/DOWNLOADS-MANIFEST.txt) and the human table in [`DOWNLOADS-MANIFEST.md`](../site/printit.verae.ai/DOWNLOADS-MANIFEST.md) **after** Windows (and other) release blobs are built, signed when required, and uploaded to the CDN. + +**Helper:** [`platform/windows/scripts/fill-manifest-sha256.ps1`](../platform/windows/scripts/fill-manifest-sha256.ps1) +**Does not:** upload, sign, rewrite site files, or touch CI secrets. +**Related:** [DUAL-SHIP.md](../platform/windows/wix/DUAL-SHIP.md) · [CI-SIGN-NOTARY.md](../platform/windows/wix/CI-SIGN-NOTARY.md) · [COMMERCIAL-INSTALLER-BACKLOG.md](COMMERCIAL-INSTALLER-BACKLOG.md) · [`.github/workflows/windows-msi-sign.yml.example`](../.github/workflows/windows-msi-sign.yml.example) + +--- + +## Order (fail closed) + +1. **Build** — NSIS `*-setup.exe` and/or full MSI via `build-full-msi.ps1` (CI unsigned: `windows-msi.yml`). +2. **Sign** — Authenticode on PE + MSI when shipping Windows trust (live CI still left; local dry-run: `sign-dry-run.ps1`; example workflow: `windows-msi-sign.yml.example`). +3. **Upload** — put the **same bytes** that customers will download on the CDN / release channel under the reserved leaf names (e.g. `VeraePrintStamp-windows-amd64-0.3.6.msi`). +4. **Hash** — run `fill-manifest-sha256.ps1` on the uploaded (or pre-upload local copy that matches CDN) files. +5. **Paste** — replace `TBD` rows in `DOWNLOADS-MANIFEST.txt` and the sha256 column in `DOWNLOADS-MANIFEST.md`. +6. **Verify** — re-download from the public URL and confirm `Get-FileHash` / `shasum -a 256` matches the pasted line. + +Do **not** paste digests for unsigned-only CI smoke MSIs into production manifest rows. Do **not** fill TBD before upload (mirror checks break). + +--- + +## fill-manifest-sha256.ps1 + +```powershell +# From repo root (Windows). Paths = the signed blobs you just uploaded (or identical local copies). +powershell -NoProfile -ExecutionPolicy Bypass -File platform\windows\scripts\fill-manifest-sha256.ps1 ` + -Path .\dist\VeraePrintStamp-windows-amd64-0.3.6-setup.exe, + .\platform\windows\wix\out\VeraePrintStamp-windows-amd64-0.3.6.msi ` + -Format both +``` + +| Flag | Effect | +|------|--------| +| `-Path` | One or more MSI / setup.exe / bare helper EXE paths | +| `-Format txt` | SHA256SUMS lines only (default) — paste into `DOWNLOADS-MANIFEST.txt` | +| `-Format md` | Markdown table rows for `DOWNLOADS-MANIFEST.md` | +| `-Format both` | txt, blank line, then md | +| `-AsName ` | Override leaf name when local build name ≠ reserved CDN filename (single path) | + +Output shape (txt): + +```text + +``` + +Two spaces between digest and name (same as existing `.txt` / `shasum -a 256`). + +POSIX equivalent if the script is unavailable: + +```bash +shasum -a 256 VeraePrintStamp-windows-amd64-0.3.6-setup.exe \ + VeraePrintStamp-windows-amd64-0.3.6.msi +# lowercase hex; paste over TBD +``` + +--- + +## 0.3.6 Windows dual-ship placeholders + +Reserved rows (still **TBD** until signed publish): + +| Leaf | Channel | +|------|---------| +| `VeraePrintStamp-windows-amd64-0.3.6-setup.exe` | NSIS primary (until thin-NSIS exit A–D) | +| `VeraePrintStamp-windows-arm64-0.3.6-setup.exe` | NSIS arm64 | +| `VeraePrintStamp-windows-amd64-0.3.6.msi` | Full MSI dual-ship | +| `VeraePrintStamp-windows-arm64-0.3.6.msi` | Full MSI arm64 | +| `verae-print-windows-amd64-0.3.6.exe` | Optional bare helper | + +After a successful hash run, replace each matching `TBD ` line in `.txt` and the **TBD** cell in `.md`. Leave other series (live 0.3.2 / 0.1.2) unchanged unless those blobs are re-uploaded. + +--- + +## What this does **not** claim + +- Authenticode or SmartScreen green (needs live sign + clean-VM proof). +- MSI as site primary download (thin-NSIS checklist still open). +- Automatic site deploy from CI (no publish job rewrites manifests). +- macOS notary staple (separate: `NOTARIZE-CHECKLIST.md` / `ci-notarize.yml.example`). + +--- + +## License + +UNLICENSED © Verae — operator docs only. diff --git a/internal/pdfcert/fallback.go b/internal/pdfcert/fallback.go index ccb8adc..fd6b72d 100644 --- a/internal/pdfcert/fallback.go +++ b/internal/pdfcert/fallback.go @@ -3,6 +3,7 @@ package pdfcert import ( "bytes" "fmt" + "log" "os" "path/filepath" "strconv" @@ -13,6 +14,9 @@ import ( qrcode "github.com/skip2/go-qrcode" ) +// brandTitleFamily is the gofpdf family name when a brand TTF/OTF registers. +const brandTitleFamily = "VeraeBrand" + // Brand tokens (CERT-01 / BrandingBook): plum, gold, ink, muted, ok. const ( plumR, plumG, plumB = 83, 27, 89 // #531B59 @@ -28,13 +32,10 @@ const ( // Minimal fallback when Node/jsPDF is unavailable. // -// Fonts: PDF core / library faces only (Times title, Helvetica body). -// EmbedBrandFonts defaults false (see EmbedBrandFontsEnabled). Do not claim -// Fraunces embed — no AddFont/TTF path unless EmbedBrandFonts is on and a -// licensed Fraunces file is wired later (OPEN-ISSUES #7). +// Fonts: PDF core faces by default (Times title, Helvetica body). +// When EmbedBrandFonts is on and BrandFontsDir has a .ttf/.otf, attempt +// gofpdf AddUTF8FontFromBytes for the title; on failure log and keep Times. func buildFallbackGofpdf(in Input, docName, created, scope, merkle, lookup string) ([]byte, error) { - // Opt-in brand embed is not implemented yet; keep core fonts when false (default). - _ = EmbedBrandFontsEnabled(in) pdf := gofpdf.New("P", "pt", "Letter", "") pdf.SetCompression(false) pdf.SetMargins(48, 48, 48) @@ -43,6 +44,8 @@ func buildFallbackGofpdf(in Input, docName, created, scope, merkle, lookup strin pageW, pageH := pdf.GetPageSize() margin := 48.0 + titleFamily := tryRegisterBrandTitleFont(pdf, in) + // CERT-01 double frame: plum outer, gold inner pdf.SetDrawColor(plumR, plumG, plumB) pdf.SetLineWidth(1.5) @@ -64,9 +67,13 @@ func buildFallbackGofpdf(in Input, docName, created, scope, merkle, lookup strin pdf.SetXY(margin, 92) pdf.CellFormat(pageW-margin*2, 11, "TIMESTAMP CERTIFICATE", "", 0, "C", false, 0, "") - // Title — Times core serif stand-in for CERT-01 Fraunces (no embed unless present) + // Title — brand face when registered; else Times core serif stand-in pdf.SetTextColor(inkR, inkG, inkB) - pdf.SetFont("Times", "B", 22) + if titleFamily != "" { + pdf.SetFont(titleFamily, "B", 22) + } else { + pdf.SetFont("Times", "B", 22) + } pdf.SetXY(margin, 106) pdf.CellFormat(pageW-margin*2, 24, "Verae Timestamp Certificate", "", 0, "C", false, 0, "") @@ -206,6 +213,43 @@ func buildFallbackGofpdf(in Input, docName, created, scope, merkle, lookup strin return buf.Bytes(), nil } +// tryRegisterBrandTitleFont attempts gofpdf UTF-8 embed of a licensed brand +// serif when EmbedBrandFonts is on and BrandFontsDir has .ttf/.otf. +// Returns brandTitleFamily on success, "" on skip/failure (caller keeps Times). +// Never errors the PDF build — registration failure is a logged no-op. +func tryRegisterBrandTitleFont(pdf *gofpdf.Fpdf, in Input) string { + if pdf == nil || !EmbedBrandFontsEnabled(in) { + return "" + } + dir := BrandFontsDir() + face := findBrandFaceFile(dir) + if face == "" { + log.Printf("pdfcert: EmbedBrandFonts on but no .ttf/.otf in %q — keeping Times", dir) + return "" + } + ext := strings.ToLower(filepath.Ext(face)) + // gofpdf AddUTF8FontFromBytes parses TrueType tables; OTF-CFF often fails. + b, err := os.ReadFile(face) + if err != nil { + log.Printf("pdfcert: brand font read %s: %v — keeping Times", face, err) + return "" + } + pdf.AddUTF8FontFromBytes(brandTitleFamily, "B", b) + if err := pdf.Error(); err != nil { + log.Printf("pdfcert: gofpdf could not register brand font %s (%s): %v — keeping Times", face, ext, err) + pdf.ClearError() + return "" + } + // Regular style alias so SetFont(family, "", …) would also work if needed. + pdf.AddUTF8FontFromBytes(brandTitleFamily, "", b) + if err := pdf.Error(); err != nil { + pdf.ClearError() + // Bold alone is enough for the title. + } + log.Printf("pdfcert: registered brand title font from %s", face) + return brandTitleFamily +} + // finalBannerTitle uses statuslang.Label("final", block) — BrandingBook chip text. // Certificates are issued only for evidence states (IsEvidence); hold ≠ evidence. func finalBannerTitle(merkle string) string { diff --git a/internal/pdfcert/pdfcert.go b/internal/pdfcert/pdfcert.go index b8329b2..b3baf89 100644 --- a/internal/pdfcert/pdfcert.go +++ b/internal/pdfcert/pdfcert.go @@ -35,17 +35,17 @@ type Input struct { VerifyURL string Channel string ByteLength int64 - // EmbedBrandFonts requests PDF embed of a licensed brand serif (Fraunces) - // when a face file is wired. Default false: core Times/Helvetica only. - // Also enabled via env VERAE_EMBED_BRAND_FONTS=1|true|yes (see EmbedBrandFontsEnabled). - // Today both paths still use standard fonts — no silent proprietary embed. + // EmbedBrandFonts requests PDF embed of a licensed brand serif when a + // .ttf/.otf is present under BrandFontsDir(). Default false: core + // Times/Helvetica only. Also via VERAE_EMBED_BRAND_FONTS=1|true|yes. + // gofpdf path attempts AddUTF8FontFromBytes; failure is a logged no-op. EmbedBrandFonts bool } // EmbedBrandFontsEnabled reports whether brand-font PDF embed is requested. // Default false. True only when Input.EmbedBrandFonts is set or env -// VERAE_EMBED_BRAND_FONTS is 1/true/yes. Embed still requires a licensed face -// on disk; until that lands, Build keeps Times/Helvetica (OPEN-ISSUES #7). +// VERAE_EMBED_BRAND_FONTS is 1/true/yes. When enabled, gofpdf may register a +// .ttf/.otf from BrandFontsDir(); failure is a logged no-op (core fonts). func EmbedBrandFontsEnabled(in Input) bool { if in.EmbedBrandFonts { return true @@ -59,6 +59,81 @@ func EmbedBrandFontsEnabled(in Input) bool { } } +// BrandFontsDir is the directory scanned for .ttf/.otf when EmbedBrandFonts is on. +// Override with VERAE_BRAND_FONTS_DIR; else site/printit.verae.ai/fonts from the +// module tree. Empty when neither is available (embed no-ops with a log). +func BrandFontsDir() string { + if d := strings.TrimSpace(os.Getenv("VERAE_BRAND_FONTS_DIR")); d != "" { + if st, err := os.Stat(d); err == nil && st.IsDir() { + return d + } + } + var candidates []string + if wd, err := os.Getwd(); err == nil { + dir := wd + for i := 0; i < 8; i++ { + candidates = append(candidates, filepath.Join(dir, "site", "printit.verae.ai", "fonts")) + if st, se := os.Stat(filepath.Join(dir, "go.mod")); se == nil && !st.IsDir() { + break + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + } + candidates = append(candidates, filepath.Join("site", "printit.verae.ai", "fonts")) + for _, c := range candidates { + if st, err := os.Stat(c); err == nil && st.IsDir() { + abs, _ := filepath.Abs(c) + return abs + } + } + return "" +} + +// findBrandFaceFile returns the preferred .ttf/.otf under dir (Fraunces-named +// first, then any ttf, then otf). Empty when none present. +func findBrandFaceFile(dir string) string { + if dir == "" { + return "" + } + ents, err := os.ReadDir(dir) + if err != nil { + return "" + } + var frauncesTTF, anyTTF, frauncesOTF, anyOTF string + for _, e := range ents { + if e.IsDir() { + continue + } + name := e.Name() + low := strings.ToLower(name) + path := filepath.Join(dir, name) + switch { + case strings.HasSuffix(low, ".ttf"): + if strings.Contains(low, "fraunces") && frauncesTTF == "" { + frauncesTTF = path + } else if anyTTF == "" { + anyTTF = path + } + case strings.HasSuffix(low, ".otf"): + if strings.Contains(low, "fraunces") && frauncesOTF == "" { + frauncesOTF = path + } else if anyOTF == "" { + anyOTF = path + } + } + } + for _, p := range []string{frauncesTTF, anyTTF, frauncesOTF, anyOTF} { + if p != "" { + return p + } + } + return "" +} + // ContentMultihash returns sha2-256 multihash hex (code 0x12, len 0x20 → f1220…) // when sha256 is a 64-char hex digest; otherwise "" (caller omits the field). func ContentMultihash(sha256 string) string { @@ -126,14 +201,11 @@ func LookupURL(sha256, receiptID string) string { // evidence copy). Prefers platform/registration-pdf/build.mjs when Node is // available; otherwise gofpdf fallback. // -// Typography: both paths use PDF standard fonts (Times title, Helvetica body). -// EmbedBrandFonts defaults false (env VERAE_EMBED_BRAND_FONTS off). Fraunces is -// the brand serif on web CERT shells; PDF embed stays opt-in and inactive until -// a licensed face is wired — never claim Fraunces in the issued PDF by default. +// Typography: default PDF standard fonts (Times title, Helvetica body). +// EmbedBrandFonts defaults false (env VERAE_EMBED_BRAND_FONTS off). When on, +// gofpdf fallback may register a licensed .ttf/.otf from BrandFontsDir(); +// failure is a logged no-op. Node path stays core faces (OPEN-ISSUES #7). func Build(in Input) ([]byte, error) { - // EmbedBrandFontsEnabled(in) is consulted when a TTF/OFL path is added; - // until then standard faces only (default false — OPEN-ISSUES #7). - _ = EmbedBrandFontsEnabled(in) created := in.CreatedAt if created == "" { // Do not invent a ledger time — leave blank for the PDF field. diff --git a/internal/pdfcert/pdfcert_test.go b/internal/pdfcert/pdfcert_test.go index addfb28..6ab464f 100644 --- a/internal/pdfcert/pdfcert_test.go +++ b/internal/pdfcert/pdfcert_test.go @@ -3,6 +3,7 @@ package pdfcert import ( "bytes" "os" + "path/filepath" "strings" "testing" ) @@ -282,9 +283,8 @@ func TestEmbedBrandFontsDefaultFalse(t *testing.T) { } } -// TestBuildUsesSystemFontsNotFraunces: issued PDF uses core Times/Helvetica; -// EmbedBrandFonts defaults false — do not claim Fraunces embed until a licensed -// face is explicitly added and the option is on (I7). +// TestBuildUsesSystemFontsNotFraunces: issued PDF uses core Times/Helvetica when +// EmbedBrandFonts defaults false (OPEN-ISSUES #7). func TestBuildUsesSystemFontsNotFraunces(t *testing.T) { t.Setenv("VERAE_EMBED_BRAND_FONTS", "") sha := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" @@ -353,3 +353,117 @@ func TestBuildUsesSystemFontsNotFraunces(t *testing.T) { t.Fatal("build.mjs should keep times/helvetica standard faces") } } + +// TestEmbedBrandFontsTrueNoFaceKeepsTimes: opt-in with empty fonts dir is a no-op. +func TestEmbedBrandFontsTrueNoFaceKeepsTimes(t *testing.T) { + empty := t.TempDir() + t.Setenv("VERAE_BRAND_FONTS_DIR", empty) + t.Setenv("VERAE_EMBED_BRAND_FONTS", "1") + sha := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + in := Input{ + DocumentName: "NoFace.pdf", + SHA256: sha, + ReceiptID: "QmNoFace", + CreatedAt: "2026-10-03T12:00:00Z", + Merkle: "99", + EmbedBrandFonts: true, + } + if !EmbedBrandFontsEnabled(in) { + t.Fatal("expected embed request enabled") + } + if findBrandFaceFile(empty) != "" { + t.Fatal("empty dir must yield no face") + } + pdf, err := buildFallbackGofpdf(in, "NoFace.pdf", in.CreatedAt, "GLOBAL", in.Merkle, LookupURL(sha, in.ReceiptID)) + if err != nil { + t.Fatal(err) + } + s := string(pdf) + if !strings.Contains(s, "Times-Bold") && !strings.Contains(s, "/BaseFont /Times") && !strings.Contains(s, "Times-Roman") { + t.Fatal("empty brand fonts dir must keep Times title") + } + if strings.Contains(s, brandTitleFamily) { + t.Fatal("must not name VeraeBrand family without a registered face") + } +} + +// TestTryRegisterBrandFontWithTTF: when EmbedBrandFonts is on and a .ttf is +// present, gofpdf registers it for the title (or no-ops cleanly if unsupported). +func TestTryRegisterBrandFontWithTTF(t *testing.T) { + ttf := findTestTTF(t) + if ttf == "" { + t.Skip("no sample .ttf available for gofpdf register smoke") + } + dir := t.TempDir() + dst := filepath.Join(dir, "sample.ttf") + b, err := os.ReadFile(ttf) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dst, b, 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("VERAE_BRAND_FONTS_DIR", dir) + t.Setenv("VERAE_EMBED_BRAND_FONTS", "") + sha := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + in := Input{ + DocumentName: "BrandFace.pdf", + SHA256: sha, + ReceiptID: "QmBrand", + CreatedAt: "2026-10-03T12:00:00Z", + Merkle: "99", + EmbedBrandFonts: true, + } + if got := findBrandFaceFile(dir); got != dst { + t.Fatalf("findBrandFaceFile: got %q want %q", got, dst) + } + pdf, err := buildFallbackGofpdf(in, "BrandFace.pdf", in.CreatedAt, "GLOBAL", in.Merkle, LookupURL(sha, in.ReceiptID)) + if err != nil { + t.Fatal(err) + } + if len(pdf) < 1000 || !bytes.HasPrefix(pdf, []byte("%PDF")) { + t.Fatal("expected valid PDF after brand-font attempt") + } + // Default false still skips even when a ttf sits on disk. + t.Setenv("VERAE_EMBED_BRAND_FONTS", "") + off := Input{SHA256: sha, ReceiptID: "QmOff", Merkle: "1"} + if EmbedBrandFontsEnabled(off) { + t.Fatal("default must stay false with ttf present") + } + core, err := buildFallbackGofpdf(off, "Off.pdf", "2026-10-03T12:00:00Z", "GLOBAL", "1", LookupURL(sha, "QmOff")) + if err != nil { + t.Fatal(err) + } + cs := string(core) + if !strings.Contains(cs, "Times-Bold") && !strings.Contains(cs, "/BaseFont /Times") { + t.Fatal("EmbedBrandFonts false must keep Times even if ttf exists") + } +} + +func findTestTTF(t *testing.T) string { + t.Helper() + candidates := []string{ + "/Library/Fonts/Arial Unicode.ttf", + "/System/Library/Fonts/Supplemental/Arial.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSerif-Bold.ttf", + } + // gofpdf module sample faces (dev machines with module cache). + if gopath := os.Getenv("GOPATH"); gopath != "" { + candidates = append(candidates, + filepath.Join(gopath, "pkg/mod/github.com/phpdave11/gofpdf@v1.4.2/font/DejaVuSansCondensed.ttf"), + ) + } + home, _ := os.UserHomeDir() + if home != "" { + candidates = append(candidates, + filepath.Join(home, "go/pkg/mod/github.com/phpdave11/gofpdf@v1.4.2/font/DejaVuSansCondensed.ttf"), + ) + } + for _, c := range candidates { + if st, err := os.Stat(c); err == nil && st.Size() > 1000 { + return c + } + } + return "" +} diff --git a/platform/windows/scripts/sign-dry-run.ps1 b/platform/windows/scripts/sign-dry-run.ps1 index 678b21b..73f59dd 100644 --- a/platform/windows/scripts/sign-dry-run.ps1 +++ b/platform/windows/scripts/sign-dry-run.ps1 @@ -335,7 +335,8 @@ end { Write-Host " # 4) Hash for DOWNLOADS-MANIFEST (after upload), no sign:" -ForegroundColor Cyan Write-Host " powershell -NoProfile -ExecutionPolicy Bypass -File platform\windows\scripts\fill-manifest-sha256.ps1 -Path ," Write-Host "" - Write-Host " Live CI: enable commented sign step in .github/workflows/windows-msi.yml (still left)." -ForegroundColor DarkGray + Write-Host " Live CI: enable windows-msi-sign.yml.example / commented steps in windows-msi.yml (still left)." -ForegroundColor DarkGray + Write-Host " After sign+upload: docs/PUBLISH-DIGESTS.md + fill-manifest-sha256.ps1" -ForegroundColor DarkGray Write-Host " This dry-run does not claim Authenticode shipped." -ForegroundColor DarkGray exit 0 diff --git a/platform/windows/wix/CI-SIGN-NOTARY.md b/platform/windows/wix/CI-SIGN-NOTARY.md index 155b416..218ad3e 100644 --- a/platform/windows/wix/CI-SIGN-NOTARY.md +++ b/platform/windows/wix/CI-SIGN-NOTARY.md @@ -1,10 +1,12 @@ # CI — Authenticode (Windows) + notarytool (macOS) -**Status:** unsigned MSI CI is live (`.github/workflows/windows-msi.yml`); **sign/notary steps remain draft**. No secrets in git. +**Status:** unsigned MSI CI is live (`.github/workflows/windows-msi.yml`); **sign/notary steps remain draft** (commented secret **names** + example workflow). No secrets in git. **Audience:** release engineers wiring GitHub Actions or Forgejo Actions. **Related:** [`Product-Full.wxs`](Product-Full.wxs), [`SILENT_PROPS.md`](SILENT_PROPS.md), [`DUAL-SHIP.md`](DUAL-SHIP.md) § CI artifact, -[`.github/workflows/windows-msi.yml`](../../../.github/workflows/windows-msi.yml), +[`.github/workflows/windows-msi.yml`](../../../.github/workflows/windows-msi.yml) — unsigned build + commented signtool / Trusted Signing, +[`.github/workflows/windows-msi-sign.yml.example`](../../../.github/workflows/windows-msi-sign.yml.example) — full sign sketch (`if: false`; secret **names** only), +[`docs/PUBLISH-DIGESTS.md`](../../../docs/PUBLISH-DIGESTS.md) — fill DOWNLOADS-MANIFEST after sign via `fill-manifest-sha256.ps1`, [`docs/COMMERCIAL-INSTALLER-BACKLOG.md`](../../../docs/COMMERCIAL-INSTALLER-BACKLOG.md), [`platform/windows/scripts/sign-dry-run.ps1`](../scripts/sign-dry-run.ps1) — local `signtool verify` + print real sign commands (**no secrets**; exit 0 if no signtool), [`platform/macos/menubar/notary-dry-run.sh`](../../macos/menubar/notary-dry-run.sh) — local notarytool recipe / tip (**no secrets**; submit only if `NOTARY_SUBMIT=1`), @@ -62,77 +64,10 @@ Match names already used in operator `signing.env` where possible; map in the wo ## GitHub Actions sketch (Windows MSI + sign) -```yaml -# .github/workflows/windows-msi-sign.yml — DRAFT, not enabled -name: windows-msi-sign -on: - workflow_dispatch: - # push: - # tags: ["printstamp-v*"] +**Canonical example file:** [`.github/workflows/windows-msi-sign.yml.example`](../../../.github/workflows/windows-msi-sign.yml.example) +(Path A: PFX + `signtool`; Path B: Azure Trusted Signing; job `if: false`; secret **names** only.) -jobs: - msi: - runs-on: windows-latest - permissions: - contents: read - # id-token: write # if using Azure OIDC Trusted Signing - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-go@v5 - with: - go-version-file: apps/PrintToVeraeTimeStamp/go.mod - # adjust path if workflow lives inside the app repo root - - - name: Build Windows binaries - shell: pwsh - working-directory: apps/PrintToVeraeTimeStamp # or repo root - run: | - # Prefer existing build-windows.ps1 / cross-build; stage under platform/windows/wix/StageFull - # go build → StageFull\bin\verae-print.exe + verae-printhelper.exe - # copy scripts + LICENSE + README-WINDOWS.txt into StageFull - Write-Host "TODO: wire build-windows.ps1 staging for Product-Full.wxs" - - - name: Install WiX toolset - shell: pwsh - run: | - # choco install wixtoolset --version 3.14.x OR use preinstalled image - Write-Host "TODO: ensure candle/light on PATH" - - - name: candle + light Product-Full - shell: pwsh - run: | - # candle -dProductVersion=… -dStageDir=… Product-Full.wxs - # light → out\VeraePrintStamp-windows-amd64-.msi - # Expect failure until StageFull complete — see Product-Full.wxs header - Write-Host "TODO: invoke candle/light" - - - name: Import code-signing cert - shell: pwsh - env: - WINDOWS_CERT_PFX_BASE64: ${{ secrets.WINDOWS_CERT_PFX_BASE64 }} - WINDOWS_CERT_PFX_PASSWORD: ${{ secrets.WINDOWS_CERT_PFX_PASSWORD }} - run: | - # $bytes = [Convert]::FromBase64String($env:WINDOWS_CERT_PFX_BASE64) - # [IO.File]::WriteAllBytes("$env:RUNNER_TEMP\codesign.pfx", $bytes) - # Import-PfxCertificate -FilePath ... -CertStoreLocation Cert:\CurrentUser\My -Password ... - Write-Host "TODO: import PFX; never Upload-Artifact the PFX" - - - name: signtool Authenticode - shell: cmd - env: - WINDOWS_SIGN_TIMESTAMP_URL: ${{ vars.WINDOWS_SIGN_TIMESTAMP_URL }} - run: | - REM signtool sign /fd SHA256 /tr %WINDOWS_SIGN_TIMESTAMP_URL% /td SHA256 /a ^ - REM path\to\verae-print.exe path\to\verae-printhelper.exe path\to\*.msi - echo TODO: signtool after binaries + MSI exist - - - name: Checksums - shell: pwsh - run: | - # Get-FileHash … SHA256 | Out-File SHA256SUMS - Write-Host "TODO: publish MSI + SHA256SUMS as release assets" -``` +Also mirrored as commented blocks in [`.github/workflows/windows-msi.yml`](../../../.github/workflows/windows-msi.yml) under the unsigned job. **Forgejo Actions:** same steps; replace `${{ secrets.* }}` with Forgejo secret syntax (`secrets.WINDOWS_CERT_PFX_BASE64`, etc.) and `runs-on` with your Windows label. Keep secret **names** identical so docs stay portable. @@ -192,7 +127,7 @@ GHA/Forgejo copy-paste sketch (unsigned default, notary job gated off): [`ci-not 2. **Sign** Windows PE + MSI with `signtool` (or Trusted Signing). 3. **Notarize** macOS zip/pkg with `notarytool --wait`. 4. **Staple** macOS artifacts; `stapler validate`. -5. **Checksum** + upload release assets; update `site/printit.verae.ai/DOWNLOADS-MANIFEST.txt` out-of-band (`platform/windows/scripts/fill-manifest-sha256.ps1` prints rows only — does not upload) or via a docs job. +5. **Checksum** + upload release assets; update `site/printit.verae.ai/DOWNLOADS-MANIFEST.txt` out-of-band — see [`docs/PUBLISH-DIGESTS.md`](../../../docs/PUBLISH-DIGESTS.md) (`fill-manifest-sha256.ps1` prints rows only — does not upload). 6. Never upload raw PFX / `.p8` as workflow artifacts. --- @@ -207,11 +142,12 @@ GHA/Forgejo copy-paste sketch (unsigned default, notary job gated off): [`ci-not **Not done:** -- Enable Authenticode `signtool` / Trusted Signing in that workflow (commented; secret **names** only). +- **Enable** Authenticode `signtool` / Trusted Signing (example + comments exist; live job still off). - Provide real certs, Team IDs, or TSA URLs. - Replace NSIS as primary Windows download. - Automate menubar notarization inside `build-menubar-app.sh` (still checklist + backlog). - Claim SmartScreen reputation or Gatekeeper green without clean-VM proof. +- Paste real 0.3.6 digests into DOWNLOADS-MANIFEST (runbook: PUBLISH-DIGESTS.md). --- diff --git a/site/printit.verae.ai/DOWNLOADS-MANIFEST.md b/site/printit.verae.ai/DOWNLOADS-MANIFEST.md index bf2ec85..efe28d1 100644 --- a/site/printit.verae.ai/DOWNLOADS-MANIFEST.md +++ b/site/printit.verae.ai/DOWNLOADS-MANIFEST.md @@ -6,7 +6,7 @@ Human-readable companion to [`DOWNLOADS-MANIFEST.txt`](DOWNLOADS-MANIFEST.txt) ( **Last-published CDN blobs:** mostly **0.3.2** (Windows amd64 NSIS / macOS / Linux) or **0.1.2** (Windows arm64, `.deb`, bare `.zip`). **Rule:** do not replace a checksum line until the matching artifact is built, signed (when required), uploaded, and the digest verified. `TBD` is intentional — not a shipping claim. -Related: [DUAL-SHIP.md](../../platform/windows/wix/DUAL-SHIP.md) · [VM-DUAL-SHIP-CHECKLIST.md](../../platform/windows/wix/VM-DUAL-SHIP-CHECKLIST.md) · [COMMERCIAL-INSTALLER-BACKLOG.md](../../docs/COMMERCIAL-INSTALLER-BACKLOG.md) · [`fill-manifest-sha256.ps1`](../../platform/windows/scripts/fill-manifest-sha256.ps1) (local hash → manifest row; **does not upload**). +Related: [DUAL-SHIP.md](../../platform/windows/wix/DUAL-SHIP.md) · [VM-DUAL-SHIP-CHECKLIST.md](../../platform/windows/wix/VM-DUAL-SHIP-CHECKLIST.md) · [COMMERCIAL-INSTALLER-BACKLOG.md](../../docs/COMMERCIAL-INSTALLER-BACKLOG.md) · [PUBLISH-DIGESTS.md](../../docs/PUBLISH-DIGESTS.md) (after sign → hash → paste) · [`fill-manifest-sha256.ps1`](../../platform/windows/scripts/fill-manifest-sha256.ps1) (local hash → manifest row; **does not upload**). --- diff --git a/site/printit.verae.ai/DOWNLOADS-MANIFEST.txt b/site/printit.verae.ai/DOWNLOADS-MANIFEST.txt index 6409c74..d48afa6 100644 --- a/site/printit.verae.ai/DOWNLOADS-MANIFEST.txt +++ b/site/printit.verae.ai/DOWNLOADS-MANIFEST.txt @@ -46,7 +46,8 @@ TBD VeraePrintStamp-windows-arm64-0.3.6.msi # Optional companion EXE (parity with prior 0.3.x bare helper uploads): TBD verae-print-windows-amd64-0.3.6.exe # See also DOWNLOADS-MANIFEST.md (human table) and VM-DUAL-SHIP-CHECKLIST.md. -# Fill digests: platform/windows/scripts/fill-manifest-sha256.ps1 -Path (prints rows; does not upload). +# Fill digests after sign+upload: docs/PUBLISH-DIGESTS.md +# platform/windows/scripts/fill-manifest-sha256.ps1 -Path (prints rows; does not upload). 56cb72d8d65c5d2e1292b0a05f235ded0a267e66530229a53980887fc5de1b72 verae-print-windows-amd64-0.3.1.exe aa2e7f1843ccbd4d466c66e92a5460f0e3d499e6af0dcba2f93cbd49bc2012e5 verae-print-windows-amd64-0.3.2.exe 079482b38ae3b49b268863209e309bb35072a3ec136f076f0ec5062d67a7c26e verae-print_0.1.2_amd64.deb diff --git a/site/printit.verae.ai/fonts/LICENSE-FONTS.md b/site/printit.verae.ai/fonts/LICENSE-FONTS.md index 65ccd22..3a6aa19 100644 --- a/site/printit.verae.ai/fonts/LICENSE-FONTS.md +++ b/site/printit.verae.ai/fonts/LICENSE-FONTS.md @@ -18,7 +18,8 @@ Optional mono: JetBrains Mono is also OFL; same operator drop-in rules. ## PDF certificates Issued PDFs (`internal/pdfcert`) use core Times/Helvetica by default. -`EmbedBrandFonts` is **off** unless explicitly enabled — and even then requires a licensed face on disk (not provided here). See package comments. +`EmbedBrandFonts` is **off** unless explicitly enabled (`Input` or `VERAE_EMBED_BRAND_FONTS`). +When on, gofpdf may register a licensed `.ttf`/`.otf` from `VERAE_BRAND_FONTS_DIR` or this directory; unsupported faces log and keep Times (not provided here). ## See also diff --git a/site/printit.verae.ai/fonts/README.md b/site/printit.verae.ai/fonts/README.md index 643e30c..dc86bd1 100644 --- a/site/printit.verae.ai/fonts/README.md +++ b/site/printit.verae.ai/fonts/README.md @@ -74,7 +74,7 @@ review. This tree must not ship proprietary font files. | `certificate.html` title | Local `fonts/*.woff2` if present, else **Fraunces** CDN | Georgia / Times New Roman / serif | | Mono digests | Local mono woff2 if present, else **JetBrains Mono** CDN | ui-monospace / SF Mono / Menlo | | UI kit (`verae-ui-kit.css`) | Inter + Material Symbols Rounded CDN | system stacks in `brand/tokens.css` | -| Issued PDF (`internal/pdfcert`) | Core Times/Helvetica; **`EmbedBrandFonts` default false** (env `VERAE_EMBED_BRAND_FONTS`) | N/A until opt-in + licensed face | +| Issued PDF (`internal/pdfcert`) | Core Times/Helvetica; **`EmbedBrandFonts` default false** (env `VERAE_EMBED_BRAND_FONTS`); opt-in gofpdf `AddUTF8FontFromBytes` for `.ttf`/`.otf` under `VERAE_BRAND_FONTS_DIR` or this dir | Times on fail / no face (logged no-op) | Design source of truth: `verae-2026-design/ui-designs/src/html/CERT-01.html` (tokens `--font-serif: 'Fraunces'`; design-kit `src/fonts/` ships Cantarell + @@ -113,7 +113,7 @@ operator uncomments a pinned-commit block after license review. | Item | Owner / later | |------|----------------| -| PDF CERT Fraunces embed | **EmbedBrandFonts default false** (option + `VERAE_EMBED_BRAND_FONTS`); Times/Helvetica until enabled + licensed face | +| PDF CERT Fraunces embed | **EmbedBrandFonts default false**; when on, gofpdf registers first `.ttf`/`.otf` in `VERAE_BRAND_FONTS_DIR` or this dir (fail → log + Times). Node `build.mjs` stays core faces | | Inter / Material Symbols self-host for whole site | optional; CERT title is OPEN-ISSUES #7 residual | | Cantarell from design-kit TTFs | GNOME mock only; not required for CERT-01 web title | | Pixel-perfect CERT-01.html match | Still later (OPEN-ISSUES Advanced) | @@ -128,8 +128,9 @@ operator uncomments a pinned-commit block after license review. | Local-first `@font-face` else CDN | **Done** — `certificate.html` probes `fonts/*.woff2` | | Drop-in docs (this README) | **Done** | | woff2 binaries + OFL in git | **Not started** — operator drop-in only ([LICENSE-FONTS.md](./LICENSE-FONTS.md)) | -| `EmbedBrandFonts` default false | **Done** — opt-in env/option; no embed path yet | -| Pixel match / PDF embed claim | **Not started** — do not claim | +| `EmbedBrandFonts` default false | **Done** — opt-in env/option | +| PDF gofpdf ttf/otf register attempt | **Done** — opt-in; no-op + log when missing/unsupported | +| Pixel match / OFL face in git | **Not started** — operator drop-in; do not claim full match | ## Contributing