feat: sign third-party CSRs, branded loading overlay, and dedicated data folder - #1
Merged
Conversation
…+ forge service interfaces
- 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.
…rt had only watermark)
…duce animated icon for loading, etc
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
docs/superpowers/specs/2026-05-28-csr-signing-design.md·docs/superpowers/plans/2026-05-28-csr-signing.mddocs/superpowers/specs/2026-05-29-loading-overlay-design.md·docs/superpowers/plans/2026-05-29-loading-overlay.mddocs/superpowers/specs/2026-05-29-loading-lightbox-self-update-design.md·docs/superpowers/plans/2026-05-29-loading-lightbox-self-update.mdWhat's in the dialog
DigitalSignature + KeyEncipherment(matches the create-signed dialog).DNS/IPtoggle +SanRulesvalidation as the existing create-signed dialog.Validation gates (PKCS#10)
MalformedInvalidProofOfPossessionUnsupportedKeyAlgorithmKeyTooSmallSubjectDnEmptyOrMalformedPoP uses
CertificateRequest.LoadSigningRequestPemwith signature validation on (notSkipSignatureValidation).Storage
CSR-signed certs land in the existing
JsonCertificateStorewith:IssuedFromCsr = trueSourceCsrFilename = <picked file name>PrivateKeyPath = null— the requester keeps the keyKeyUsages/ExtendedKeyUsages/Sansreflect what was actually signedActivity log kind:
"SignedFromCsr"→ dashboard renders as "Signed from CSR".UI
From CSRindicator now rendered throughStatusPill(Kind="csr"→ brand orange), sitting alongside the existing Valid/Trusted pills..pem, and the.keyonly when a private key exists).Cross-platform parity (hard constraint)
UIDocumentPickerViewControllerwith UT type fallback for.csr/.pem/.req/.txt.FileOpenPickerwith matching extensions.MauiCsrFilePicker.Security follow-ups from review
BuildCsrKeyUsageFlagsregardless 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: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.jsonand thecertificates/tree directly into the platform app-data root — on an unsandboxed macCatalyst release that littered~/Libraryitself. All data now lives under a dedicatedSelfCertForgesubfolder (~/Library/SelfCertForgeon macOS,%LOCALAPPDATA%\…\SelfCertForgeon Windows via the sameFileSystem.AppDataDirectoryroot).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.MauiProgramresolves the root once and threads it through every store plus the reveal service, replacing the rawFileSystem.AppDataDirectoryusages.~/Library/*.jsonandcertificates/were moved into~/Library/SelfCertForgewith timestamps intact. Tests:DataFolderLayoutTests.Relative path storage (portable across relocations)
Earlier,
JsonCertificateStorepersisted absolutecertificatePath/privateKeyPath/outputDirectoryvalues — so the file-move migration above leftcertificates.jsonpointing 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:
StoredCertificatekeeps absolute paths in memory (callers unchanged), while the file stores paths relative to the data folder, canonicalized to the tail beginning at the lastcertificatessegment. 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.
ILoadingOverlay+RunOrDirectAsyncextension (Core, MAUI-free) — wraps an async operation, shows the overlay with a caption, and tears it down in afinally.FakeLoadingOverlaytest double keeps the ViewModels unit-testable.MauiLoadingOverlay(CommunityToolkit.MauiPopup) — transparent popup over a dimmed scrim, made thread-safe and close-crash-safe (guards double-dismiss and dismiss-after-page-teardown).LoadingOverlayContentrenders the animated brand mark + caption on a#1B1D24backdrop, mark scaled up 25%. The mark is the real animated SVG hosted in aWebView— the Lottie export only contained the static watermark, so native SVG animation is used instead.SkiaSharp.Extended.UI.Mauiwas added during the initial Lottie approach.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.DownloadAndInstallAsyncwraps the download inRunOrDirectAsync("Downloading Update…", …)and nestsRunOrDirectAsync("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.<ProgressBar>and theDownloadProgress/DownloadProgressNormalized/_downloadProgressmembers were removed.IsDownloadingstill hides the button; the statusLabelnow carries outcomes only ("Version X is available.", "You're on the latest version.", "Update failed. Please try again."). "Check for updates" is unchanged.catchwraps 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),CanExecutegating, and aMaxConcurrentDepthcounter onFakeLoadingOverlayso 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.ico,appicon.svg/appicon_windows.svg,brand_mark.svg,splash.svg, and top-levelicon.png/icon.svg.selfcertforge-designskill assets consolidated (icon-original.svg/logo.svg/mark.svgdropped in favor of a single refreshedicon.svg+icon.png).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 — deterministicInvalidProofOfPossession(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, EmailProtectionForgeServiceFromCsrTests— activity log + store wiringAuthoritiesViewModelCsrTests,CertificatesViewModelCsrTests,CreateFromCsrDialogViewModelTests,CsrValidationErrorMessagesTestsDataFolderLayoutTests— relocation, no-clobber, idempotency, same-root no-op, subfolder creationJsonCertificateStorePathTests— relative persistence, absolute resolution, relocation portability, legacy-absolute healing + eager rewrite, no-op when already canonical, null round-tripLoadingOverlayExtensionsTests—RunOrDirectAsyncshow/run/teardown ordering and the null-overlay direct pathSettingsViewModelTests(self-update) — two-phase overlay caption + nesting (MaxConcurrentDepth == 2), apply-phase failure path, no-op guards, andCanExecutegatingFakeLoadingOverlay-backed assertions across Dashboard / CreateRoot / CreateSigned / Certificates-export ViewModel testsIncidental fixes shipped with this branch
com.apple.security.app-sandboxfromEntitlements.plistso dev (make run) reads the same~/Librarydata root as the signed release, instead of~/Library/Containers/<bundle-id>/Data/Library.ColorAccentMutedtypo fixed.Accepted as-is from review