Skip to content

feat: sign third-party CSRs, branded loading overlay, and dedicated data folder - #1

Merged
rbonestell merged 56 commits into
mainfrom
feat/csr-signing
May 29, 2026
Merged

feat: sign third-party CSRs, branded loading overlay, and dedicated data folder#1
rbonestell merged 56 commits into
mainfrom
feat/csr-signing

Conversation

@rbonestell

@rbonestell rbonestell commented May 29, 2026

Copy link
Copy Markdown
Owner

Summary

Adds a "Sign a CSR" action to authorities so SelfCertForge can issue a certificate from a third-party PKCS#10 CSR — the operator picks a .csr, validates it, reviews/adjusts editable fields in a dialog, and the chosen CA signs and stores the result alongside existing forged certs (with no private key, since the requester holds it).

This PR also carries the work merged from feat/loading-overlay: a branded loading overlay shown during long-running crypto/IO, plus a brand & icon refresh (see the dedicated sections below).

Specs / plans:

  • CSR signing: docs/superpowers/specs/2026-05-28-csr-signing-design.md · docs/superpowers/plans/2026-05-28-csr-signing.md
  • Loading overlay: docs/superpowers/specs/2026-05-29-loading-overlay-design.md · docs/superpowers/plans/2026-05-29-loading-overlay.md
  • Self-update lightbox: docs/superpowers/specs/2026-05-29-loading-lightbox-self-update-design.md · docs/superpowers/plans/2026-05-29-loading-lightbox-self-update.md

What's in the dialog

  • Locked from the CSR: Subject DN, public key (algorithm + bits + SHA-256 SPKI fingerprint), raw PEM.
  • Honored when present: Key Usage and Extended Key Usage extensions — the dialog shows them as locked. When absent, KU defaults to DigitalSignature + KeyEncipherment (matches the create-signed dialog).
  • Editable: validity (days), signature hash, and Subject Alternative Names. SAN entry uses the same DNS/IP toggle + SanRules validation as the existing create-signed dialog.
  • Cancel: closes without writing anything.

Validation gates (PKCS#10)

  • Malformed PEM / truncated CSR → Malformed
  • Signature doesn't match the embedded public key (proof of possession) → InvalidProofOfPossession
  • Unsupported key algorithm (RSA only today — e.g. an ECDSA CSR) → UnsupportedKeyAlgorithm
  • RSA < 2048 → KeyTooSmall
  • Empty/malformed Subject DN → SubjectDnEmptyOrMalformed
  • Multi-error case (e.g., 1024-bit + tampered) returns all applicable errors.

PoP uses CertificateRequest.LoadSigningRequestPem with signature validation on (not SkipSignatureValidation).

Storage

CSR-signed certs land in the existing JsonCertificateStore with:

  • IssuedFromCsr = true
  • SourceCsrFilename = <picked file name>
  • PrivateKeyPath = null — the requester keeps the key
  • KeyUsages / ExtendedKeyUsages / Sans reflect what was actually signed

Activity log kind: "SignedFromCsr" → dashboard renders as "Signed from CSR".

UI

  • Authorities: new "Sign a CSR" action sits next to "Create Signed Certificate" in a single horizontal action group.
  • Certificates: From CSR indicator now rendered through StatusPill (Kind="csr" → brand orange), sitting alongside the existing Valid/Trusted pills.
  • Export menu for CSR-signed certs: "Export as PFX" is hidden (no key to bundle); "Export as PEM" works (writes the cert .pem, and the .key only when a private key exists).

Cross-platform parity (hard constraint)

  • macCatalyst file picker via UIDocumentPickerViewController with UT type fallback for .csr/.pem/.req/.txt.
  • Windows file picker via FileOpenPicker with matching extensions.
  • 1 MiB CSR size cap and surfaced read-error alert in MauiCsrFilePicker.

Security follow-ups from review

  • KeyCertSign / CrlSign stripped in BuildCsrKeyUsageFlags regardless of what the CSR or operator requests — CSR-signed certs are end-entity (BasicConstraints CA=false), so the CA-only KU bits must never propagate. Test: Strips_KeyCertSign_and_CrlSign_even_when_requested.
  • IP-address SANs preserved end-to-end (emitted with an IP: prefix and routed as IP vs DNS during signing, same as the regular signed-cert path). Test: Preserves_IP_address_SANs_from_csr_through_signing.

Data folder: dedicated subfolder + one-time migration

Earlier builds wrote certificates.json / activity.json / preferences.json and the certificates/ tree directly into the platform app-data root — on an unsandboxed macCatalyst release that littered ~/Library itself. All data now lives under a dedicated SelfCertForge subfolder (~/Library/SelfCertForge on macOS, %LOCALAPPDATA%\…\SelfCertForge on Windows via the same FileSystem.AppDataDirectory root).

  • New DataFolderLayout.Resolve(appDataRoot) computes the subfolder and runs a one-time, best-effort migration on launch: each known legacy item is moved from the old root into the subfolder only when it exists there and is absent in the new location — so re-runs are no-ops and newer data is never clobbered. Migration failures (locked/partial file) are swallowed so they never block startup.
  • MauiProgram resolves the root once and threads it through every store plus the reveal service, replacing the raw FileSystem.AppDataDirectory usages.
  • The migrator is pure file I/O (no MAUI), so Windows gets the same relocation automatically. Verified on macOS: legacy ~/Library/*.json and certificates/ were moved into ~/Library/SelfCertForge with timestamps intact. Tests: DataFolderLayoutTests.

Relative path storage (portable across relocations)

Earlier, JsonCertificateStore persisted absolute certificatePath / privateKeyPath / outputDirectory values — so the file-move migration above left certificates.json pointing at the old ~/Library/certificates/... location, breaking signing and export for migrated authorities.

The store is now the single boundary between on-disk relative paths and in-memory absolute paths: StoredCertificate keeps absolute paths in memory (callers unchanged), while the file stores paths relative to the data folder, canonicalized to the tail beginning at the last certificates segment. That one rule resolves relative, current-absolute, and stale-absolute inputs, so the JSON is portable across any future relocation. Legacy/stale absolute paths are rebased onto the current root on load and the file is eagerly rewritten to relative form (best-effort — never fails load). Tests: JsonCertificateStorePathTests.

Branded loading overlay (merged from feat/loading-overlay)

A reusable overlay now covers long-running crypto/IO so the UI never looks frozen.

  • Core abstraction: ILoadingOverlay + RunOrDirectAsync extension (Core, MAUI-free) — wraps an async operation, shows the overlay with a caption, and tears it down in a finally. FakeLoadingOverlay test double keeps the ViewModels unit-testable.
  • Wired into: root-certificate forging, signed-certificate forging, CSR signing, certificate exports, and authority exports — each with a contextual caption.
  • MAUI adapter: MauiLoadingOverlay (CommunityToolkit.Maui Popup) — transparent popup over a dimmed scrim, made thread-safe and close-crash-safe (guards double-dismiss and dismiss-after-page-teardown).
  • Content: LoadingOverlayContent renders the animated brand mark + caption on a #1B1D24 backdrop, mark scaled up 25%. The mark is the real animated SVG hosted in a WebView — the Lottie export only contained the static watermark, so native SVG animation is used instead. SkiaSharp.Extended.UI.Maui was added during the initial Lottie approach.
  • Easter egg: tapping the shell logo shows a dismissable instance of the overlay.

Loading lightbox in the self-update flow

The self-update Download & Install step now runs behind that same overlay instead of an inline progress bar:

  • SettingsViewModel.DownloadAndInstallAsync wraps the download in RunOrDirectAsync("Downloading Update…", …) and nests RunOrDirectAsync("Installing Update…", …) around apply-and-restart. The overlay's depth counter swaps the live caption in place — one popup, no flicker — and stays up through the restart hand-off.
  • Indeterminate by design: the determinate <ProgressBar> and the DownloadProgress / DownloadProgressNormalized / _downloadProgress members were removed. IsDownloading still hides the button; the status Label now carries outcomes only ("Version X is available.", "You're on the latest version.", "Update failed. Please try again."). "Check for updates" is unchanged.
  • Reviewed by a 3-agent pass (correctness / tests / architecture). Because the catch wraps both phases, the failure label was made phase-accurate ("Download failed" → "Update failed"). Added tests for the apply-phase failure path, no-op guards (no update available; already downloading), CanExecute gating, and a MaxConcurrentDepth counter on FakeLoadingOverlay so the happy-path test proves the install phase nests inside the download phase rather than running sequentially.

Brand & icon refresh

The assets(icons+logos) commit refreshes icons/logos for visual consistency and introduces the animated icon used by the overlay:

  • App icon + Windows app.ico, appicon.svg / appicon_windows.svg, brand_mark.svg, splash.svg, and top-level icon.png / icon.svg.
  • selfcertforge-design skill assets consolidated (icon-original.svg / logo.svg / mark.svg dropped in favor of a single refreshed icon.svg + icon.png).
  • Refreshed assets/screenshot-root-authorities.png.

Testing

make test (Core suite): 258 passed, 1 platform-skip (259 total) — stable across repeated full-suite runs. The one skip (ExportP7bAsync_WithIssuerInStore_WritesP7b) is an intentional platform gate: X509Certificate2Collection.Export(Pkcs7) NREs on macOS/.NET for PEM-loaded certs (passes on Windows).

A previously order-flaky CSR test (CsrInspectionTests.Rsa1024_tampered_…) was fixed here: the fixture corrupted a base64 character, which intermittently changed the decoded DER length and made the CSR unparseable (Malformed) instead of breaking only the signature. The fixture now flips a byte of the decoded DER signature, preserving structure — deterministic InvalidProofOfPossession (verified 40/40 runs).

Coverage highlights:

  • CsrInspectionTests — 4096-bit RSA inspection, IP SAN inclusion, multi-error (KeyTooSmall + InvalidProofOfPossession)
  • GenerateCertificateFromCsrTests — CA KU strip, IP SAN round-trip, EmailProtection
  • ForgeServiceFromCsrTests — activity log + store wiring
  • AuthoritiesViewModelCsrTests, CertificatesViewModelCsrTests, CreateFromCsrDialogViewModelTests, CsrValidationErrorMessagesTests
  • DataFolderLayoutTests — relocation, no-clobber, idempotency, same-root no-op, subfolder creation
  • JsonCertificateStorePathTests — relative persistence, absolute resolution, relocation portability, legacy-absolute healing + eager rewrite, no-op when already canonical, null round-trip
  • LoadingOverlayExtensionsTestsRunOrDirectAsync show/run/teardown ordering and the null-overlay direct path
  • SettingsViewModelTests (self-update) — two-phase overlay caption + nesting (MaxConcurrentDepth == 2), apply-phase failure path, no-op guards, and CanExecute gating
  • FakeLoadingOverlay-backed assertions across Dashboard / CreateRoot / CreateSigned / Certificates-export ViewModel tests

Incidental fixes shipped with this branch

  • macCatalyst dev: removed com.apple.security.app-sandbox from Entitlements.plist so dev (make run) reads the same ~/Library data root as the signed release, instead of ~/Library/Containers/<bundle-id>/Data/Library.
  • ColorAccentMuted typo fixed.

Accepted as-is from review

  • L1 (hardcoded SHA-256 for PoP) and L2 (CN/filename in activity log) were reviewed and accepted; not changed here.

rbonestell added 30 commits May 28, 2026 15:04
- Strip KeyCertSign/CrlSign in BuildCsrKeyUsageFlags so CSR-signed
  end-entity certs can never accidentally become sub-CAs, regardless
  of what the CSR or operator requests.
- Preserve IP-address SANs from CSRs: ExtractSans now emits 'IP:' -
  prefixed entries and GenerateCertificateFromCsrAsync routes
  IP vs DNS SAN values matching the existing signed-cert path.
- Add EkuEmailProtection to CsrSigningRequest, plumb through the
  VM and signing pipeline (BuildCsrEkuOids -> 1.3.6.1.5.5.7.3.4),
  and add an Email Protection row to the CSR dialog so the EKU is
  honored when present in the CSR or selected by the operator.
- MauiCsrFilePicker: enforce a 1 MiB CSR size cap and surface
  read errors as a UI alert instead of bubbling exceptions.
- Tests: 4096-bit RSA inspection, combined KeyTooSmall +
  InvalidProofOfPossession case, CA KU flags stripped at
  signing, IP-SAN round-trip, EmailProtection plumbed end-to-end.
- Seal CreateFromCsrDialogViewModelTests for consistency.
ColorAccentMuted was never defined in App.xaml — the StaticResource
lookup failed at runtime, crashing app startup with a XamlParseException
the first time CertificatesView's resource tree was resolved.
Use ColorAccentPrimaryDeep, which already exists and reads as a
subtle brand-aligned tag.
- Replace 'dns:/ip:' text prefix UX in the Sign CSR dialog with the
  same DNS/IP toggle the create-signed dialog uses, plus SanRules
  validation and an inline error label. SAN list items now show a
  Type tag (DNS/IP) on the left and the bare value next to it.
- Skip SANs from the CSR whose value equals the Subject CN — those
  ride on the cert as the Subject already and shouldn't duplicate
  into the SAN list as a separate operator-visible row.
- When the CSR ships no Key Usage extension, default to
  DigitalSignature + KeyEncipherment (matching the create-signed
  dialog defaults) and fire property notifications from Initialize
  so the bindings refresh.
The hand-rolled Border+Label badge didn't match the Valid/Trusted
status pills next to it — different shape, padding, and typography.
Add a 'csr' kind to StatusPill that maps to ColorAccentPrimary
(brand orange) and render the indicator through StatusPill so it
sits visually alongside the existing status badges.
CanExportKeyPem was tied to HasPrivateKey, but the underlying
ExportKeyPemAsync already writes the cert .pem unconditionally
and only adds the .key alongside when a private key exists.
CSR-signed certs ship without a key but the cert itself is still
exportable as PEM — gate the option on the cert path instead.

PFX requires a private key to bundle, so for CSR-signed certs
hide the row entirely (and its trailing separator) instead of
showing a disabled menu item. Drop the now-irrelevant tooltips.
…to Create Signed Certificate

Both authority actions are now siblings in a HorizontalStackLayout
so they read as a paired action group, and the long
'Create From Certificate Signing Request' label is replaced with
the punchier 'Sign a CSR'.
…root

The released, signed app ships unsandboxed, so its
FileSystem.AppDataDirectory resolves to ~/Library and certificates.json,
activity.json, and the certificates/ folder live there. With
com.apple.security.app-sandbox=true in Entitlements.plist, the dev
build resolved to ~/Library/Containers/com.rbonestell.selfcertforge.app/
Data/Library instead and 'make run' showed no existing certs, which
made local repro and debugging against real data awkward.

Drop the sandbox entitlements so dev sees the same files the signed
install writes. UIDocumentPickerViewController works without the
files.user-selected.read-write entitlement when not sandboxed, and
the network.client entitlement is moot off-sandbox.
Earlier builds wrote certificates.json, activity.json, preferences.json
and the certificates/ tree directly into the platform app-data root —
on an unsandboxed macCatalyst release that meant littering ~/Library
itself. All data now lives under <AppDataRoot>/SelfCertForge (e.g.
~/Library/SelfCertForge on macOS, %LOCALAPPDATA%\...\SelfCertForge on
Windows via the same FileSystem.AppDataDirectory root).

DataFolderLayout.Resolve computes that subfolder and runs a one-time,
best-effort migration on launch: each known legacy item is moved from
the old root into the subfolder when it exists there and is absent in
the new location, so re-runs are no-ops and newer data is never
clobbered. MauiProgram resolves the root once and threads it through
every store + the reveal service, replacing the raw
FileSystem.AppDataDirectory usages.

The migrator is pure file I/O (no MAUI), unit-tested in
DataFolderLayoutTests, and platform-agnostic so Windows gets the same
relocation behavior. Verified on macOS: legacy ~/Library/*.json and
certificates/ were moved into ~/Library/SelfCertForge with timestamps
intact.
@rbonestell rbonestell changed the title feat(csr): sign third-party Certificate Signing Requests feat: sign third-party CSRs, branded loading overlay, and dedicated data folder May 29, 2026
rbonestell added 11 commits May 29, 2026 10:33
JsonCertificateStore persisted absolute certificatePath/privateKeyPath/
outputDirectory values, so relocating the data folder (e.g. the one-time
~/Library -> ~/Library/SelfCertForge migration) left certificates.json
pointing at files that no longer existed, breaking signing and export.

The store is now the single boundary between on-disk relative paths and
in-memory absolute paths: StoredCertificate keeps absolute paths in memory
(callers unchanged) while the file holds paths relative to the data folder,
canonicalized to the tail beginning at the last "certificates" segment. That
one rule resolves relative, current-absolute, and stale-absolute inputs, so
the JSON is portable across any future relocation. Legacy/stale absolute
paths are rebased onto the current root on load and the file is eagerly
rewritten to relative form (best-effort, never fails load).

Adds JsonCertificateStorePathTests covering relative persistence, resolution,
relocation portability, legacy-path healing, eager rewrite, no-op when already
canonical, and null round-trip.
CsrInspectionTests.Rsa1024_tampered_* was order-independent but per-run
flaky (~4% failure): the fixture corrupted a base64 *character* of the CSR,
and when the edit landed on a padding position it changed the decoded DER
length, so the request no longer parsed and InspectCsrAsync returned only
Malformed instead of the expected KeyTooSmall + InvalidProofOfPossession.
Whether the final base64 group was padded depended on the random RSA modulus
high bit shifting the DER length mod 3.

Tamper on the decoded DER instead: XOR the final byte (signature content, the
last element of a CertificationRequest). That preserves all ASN.1 lengths and
structure, so the CSR still parses while its signature fails verification --
deterministic InvalidProofOfPossession every run. Verified 40/40 runs green.
…overlay nesting

Address review findings on the loading-lightbox self-update work:
- add install/apply-phase failure test (distinct from download failure)
- add no-op guard tests (no update available; already downloading)
- add CanExecute gating tests (disabled until update available; disabled while downloading)
- track MaxConcurrentDepth in FakeLoadingOverlay so the happy-path test proves the
  install phase nests inside the download phase rather than running sequentially
- make the failure label phase-accurate (the catch now wraps both phases):
  'Download failed' -> 'Update failed'
- use a named loadingOverlay: argument to de-brittle the VM construction
@rbonestell
rbonestell merged commit aa97e37 into main May 29, 2026
8 checks passed
@rbonestell
rbonestell deleted the feat/csr-signing branch May 29, 2026 22:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant