Skip to content

Commit 0a581ed

Browse files
committed
add plans and design for signed updates
1 parent f842ace commit 0a581ed

2 files changed

Lines changed: 907 additions & 0 deletions

File tree

docs/cert-playbook.md

Lines changed: 363 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,363 @@
1+
# Cert / Key Operations Playbook
2+
3+
Step-by-step procedures for every cert operation in the ModVerify update-signing chain.
4+
Background and rationale: `update-signing.md`.
5+
6+
**Trust hierarchy at a glance:**
7+
8+
```
9+
Root CA — 20-year self-signed, private key kept offline by the maintainer
10+
│ signs
11+
12+
Intermediate — 1-year, private key in GitHub Secrets, used by CI
13+
│ signs
14+
15+
Release manifest — one per release
16+
```
17+
18+
Clients embed only the **root** public cert. The verifier chains every manifest's signing
19+
cert back to that root via `X509Chain` with `CustomRootTrust` (the Windows cert store is
20+
never consulted).
21+
22+
---
23+
24+
## Non-negotiable rules
25+
26+
- **Never touch the Windows certificate store.** No `Cert:\…`, no
27+
`New-SelfSignedCertificate`, no `-CertStoreLocation` / `-TrustRoot` flags.
28+
- **Never persist trust state to writable disk** on the user's machine.
29+
- **Generate all certs in memory** via `CertificateRequest.CreateSelfSigned` /
30+
`CertificateRequest.Create(issuerCert, …)`. Export straight to `.pfx` / `.cer` files.
31+
- **The root private key never leaves the offline machine.** No CI, no cloud sync, no
32+
screenshot.
33+
- **Intermediate private key is allowed in GitHub Secrets.** It has a short lifetime by
34+
design.
35+
- **All scripts assume PowerShell 7.5+ / .NET 9+** (`X509CertificateLoader.LoadPkcs12`).
36+
37+
---
38+
39+
## 1. Initial root cert generation (one-time)
40+
41+
**When:** Before shipping the first signed release. Once, ever (except for catastrophic
42+
root rotation — section 5).
43+
44+
**Where:** Air-gapped or at least offline machine.
45+
46+
**Produces:**
47+
- `modverify-root.pfx` — root private key + cert, password-protected. **Never networked.**
48+
- `modverify-trust.cer` — root public cert. Commits to `src/ModVerify.CliApp/Resources/Certs/`.
49+
50+
### Script
51+
52+
```powershell
53+
$pwd = Read-Host "Root PFX password (write this down — losing it = losing the root)" -AsSecureString
54+
55+
$ecdsa = [System.Security.Cryptography.ECDsa]::Create(
56+
[System.Security.Cryptography.ECCurve+NamedCurves]::nistP256)
57+
try {
58+
$req = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new(
59+
"CN=ModVerify Root CA",
60+
$ecdsa,
61+
[System.Security.Cryptography.HashAlgorithmName]::SHA256)
62+
63+
# Basic Constraints: CA=true, end-entity intermediates not sub-CAs
64+
$req.CertificateExtensions.Add(
65+
[System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension]::new(
66+
$true, $true, 0, $true))
67+
68+
# Key Usage: signs other certs
69+
$req.CertificateExtensions.Add(
70+
[System.Security.Cryptography.X509Certificates.X509KeyUsageExtension]::new(
71+
[System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::KeyCertSign,
72+
$true))
73+
74+
# Subject Key Identifier — helps X509Chain link intermediates to this root
75+
$req.CertificateExtensions.Add(
76+
[System.Security.Cryptography.X509Certificates.X509SubjectKeyIdentifierExtension]::new(
77+
$req.PublicKey, $false))
78+
79+
$notBefore = [DateTimeOffset]::UtcNow.AddMinutes(-5)
80+
$notAfter = [DateTimeOffset]::UtcNow.AddYears(20)
81+
$cert = $req.CreateSelfSigned($notBefore, $notAfter)
82+
try {
83+
[IO.File]::WriteAllBytes(".\modverify-root.pfx", $cert.Export(
84+
[System.Security.Cryptography.X509Certificates.X509ContentType]::Pfx, $pwd))
85+
[IO.File]::WriteAllBytes(".\modverify-trust.cer", $cert.Export(
86+
[System.Security.Cryptography.X509Certificates.X509ContentType]::Cert))
87+
Write-Host "Root generated. Thumbprint: $($cert.Thumbprint)"
88+
} finally {
89+
$cert.Dispose()
90+
}
91+
} finally {
92+
$ecdsa.Dispose()
93+
}
94+
```
95+
96+
### After
97+
98+
1. Strong passphrase (Diceware, ≥60 bits entropy). Memorize *and* record it.
99+
2. **Back up the `.pfx` in at least two independent failure modes**:
100+
- Password manager (1Password / Bitwarden / KeePass), encrypted entry.
101+
- YubiKey PIV slot, or offline USB stored physically secured (safe, deposit box).
102+
- Optional third: printed base64 + passphrase, sealed envelope, separate location.
103+
3. Commit `modverify-trust.cer` to `src/ModVerify.CliApp/Resources/Certs/`.
104+
4. **Delete the working-copy `.pfx`** once backups are confirmed.
105+
5. Schedule the annual test ceremony (section 4) on a fixed date.
106+
107+
---
108+
109+
## 2. Issuing an intermediate (recurring)
110+
111+
**When:**
112+
- ~2 months before current intermediate expires (so there's overlap).
113+
- Immediately on suspected CI compromise.
114+
115+
**Where:** Offline (or at minimum, disconnected) machine.
116+
117+
**Produces:**
118+
- `modverify-int-YYYYMM.pfx` — intermediate keypair, password-protected, goes to GitHub
119+
Secrets.
120+
121+
### Script
122+
123+
```powershell
124+
$rootPfxPath = ".\modverify-root.pfx"
125+
$rootPwd = Read-Host "Root PFX password" -AsSecureString
126+
$intPwd = Read-Host "Intermediate PFX password (will go to GitHub Secrets)" -AsSecureString
127+
128+
$rootPwdPlain = [System.Net.NetworkCredential]::new("", $rootPwd).Password
129+
130+
$rootBytes = [IO.File]::ReadAllBytes($rootPfxPath)
131+
$rootCert = [System.Security.Cryptography.X509Certificates.X509CertificateLoader]::LoadPkcs12(
132+
$rootBytes,
133+
$rootPwdPlain,
134+
[System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet)
135+
$rootPwdPlain = $null
136+
137+
$intEcdsa = [System.Security.Cryptography.ECDsa]::Create(
138+
[System.Security.Cryptography.ECCurve+NamedCurves]::nistP256)
139+
try {
140+
$intReq = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new(
141+
"CN=ModVerify Signing $((Get-Date -Format yyyy-MM))",
142+
$intEcdsa,
143+
[System.Security.Cryptography.HashAlgorithmName]::SHA256)
144+
145+
# Basic Constraints: end-entity, not a sub-CA
146+
$intReq.CertificateExtensions.Add(
147+
[System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension]::new(
148+
$false, $false, 0, $true))
149+
150+
# Key Usage: signs data (manifests), not certs
151+
$intReq.CertificateExtensions.Add(
152+
[System.Security.Cryptography.X509Certificates.X509KeyUsageExtension]::new(
153+
[System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::DigitalSignature,
154+
$true))
155+
156+
# Subject Key Identifier
157+
$intReq.CertificateExtensions.Add(
158+
[System.Security.Cryptography.X509Certificates.X509SubjectKeyIdentifierExtension]::new(
159+
$intReq.PublicKey, $false))
160+
161+
# Sign with root
162+
$notBefore = [DateTimeOffset]::UtcNow.AddMinutes(-5)
163+
$notAfter = [DateTimeOffset]::UtcNow.AddYears(1)
164+
$serial = [System.BitConverter]::GetBytes([DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds())
165+
$intCert = $intReq.Create($rootCert, $notBefore, $notAfter, $serial)
166+
167+
$intWithKey = $intCert.CopyWithPrivateKey($intEcdsa)
168+
try {
169+
$outPath = ".\modverify-int-$((Get-Date -Format yyyyMM)).pfx"
170+
[IO.File]::WriteAllBytes($outPath, $intWithKey.Export(
171+
[System.Security.Cryptography.X509Certificates.X509ContentType]::Pfx, $intPwd))
172+
Write-Host "Intermediate written to $outPath"
173+
Write-Host "Subject: $($intCert.Subject)"
174+
Write-Host "Thumbprint: $($intCert.Thumbprint)"
175+
Write-Host "Valid until: $($intCert.NotAfter.ToString('u'))"
176+
} finally {
177+
$intWithKey.Dispose()
178+
$intCert.Dispose()
179+
}
180+
} finally {
181+
$intEcdsa.Dispose()
182+
$rootCert.Dispose()
183+
}
184+
```
185+
186+
### After
187+
188+
1. Base64 the intermediate PFX:
189+
```powershell
190+
[Convert]::ToBase64String([IO.File]::ReadAllBytes(".\modverify-int-YYYYMM.pfx")) `
191+
| Set-Clipboard
192+
```
193+
2. In GitHub: Settings → Secrets and variables → Actions:
194+
- Update `UPDATER_SIGNING_PFX_B64` with the clipboard contents.
195+
- Update `UPDATER_SIGNING_PFX_PASSWORD` with the passphrase.
196+
3. Wipe the local intermediate PFX:
197+
```powershell
198+
Remove-Item ".\modverify-int-YYYYMM.pfx" -Force
199+
```
200+
4. Lock the root PFX away again.
201+
5. Trigger a release (or wait for the next one). Confirm CI signs successfully and a
202+
freshly-installed client verifies the new manifest.
203+
204+
---
205+
206+
## 3. Local dev cert generation (for `deploy-local.ps1`)
207+
208+
Dev certs are generated fresh per `deploy-local.ps1` run — no persistence, no
209+
backups needed.
210+
211+
The pattern is already implemented in `deploy-local.ps1` and follows the same
212+
in-memory `CertificateRequest.CreateSelfSigned` shape. If `deploy-local.ps1` is rewritten
213+
for any reason, ensure it generates a *root* + *intermediate* pair (mirroring prod), so
214+
the local-deploy flow exercises the same chain-validation code path the prod verifier uses.
215+
216+
---
217+
218+
## 4. Annual root test ceremony
219+
220+
**When:** Once per year on a fixed date (e.g. every January 15). Set a calendar
221+
reminder.
222+
223+
**Why:** Confirm the root key + passphrase are still accessible *before* a real
224+
incident makes you discover otherwise. A lost root takes 6 months — 5 years to discover
225+
during normal operation.
226+
227+
### Script
228+
229+
```powershell
230+
$rootPwd = Read-Host "Root PFX password (annual test)" -AsSecureString
231+
$rootPwdPlain = [System.Net.NetworkCredential]::new("", $rootPwd).Password
232+
233+
try {
234+
$rootBytes = [IO.File]::ReadAllBytes(".\modverify-root.pfx")
235+
$rootCert = [System.Security.Cryptography.X509Certificates.X509CertificateLoader]::LoadPkcs12(
236+
$rootBytes,
237+
$rootPwdPlain,
238+
[System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet)
239+
$rootPwdPlain = $null
240+
241+
Write-Host "Root loaded:"
242+
Write-Host " Subject: $($rootCert.Subject)"
243+
Write-Host " Thumbprint: $($rootCert.Thumbprint)"
244+
Write-Host " Valid until: $($rootCert.NotAfter.ToString('u'))"
245+
246+
# Sign a throwaway test cert — confirms the private key actually works
247+
$testEcdsa = [System.Security.Cryptography.ECDsa]::Create(
248+
[System.Security.Cryptography.ECCurve+NamedCurves]::nistP256)
249+
try {
250+
$testReq = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new(
251+
"CN=Annual Test - DELETE ME",
252+
$testEcdsa,
253+
[System.Security.Cryptography.HashAlgorithmName]::SHA256)
254+
$serial = [System.BitConverter]::GetBytes([DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds())
255+
$testCert = $testReq.Create($rootCert,
256+
[DateTimeOffset]::UtcNow,
257+
[DateTimeOffset]::UtcNow.AddMinutes(5),
258+
$serial)
259+
Write-Host "Test cert signed successfully — root private key is intact."
260+
$testCert.Dispose()
261+
} finally {
262+
$testEcdsa.Dispose()
263+
}
264+
265+
# Compare loaded cert against committed trust cert
266+
$embeddedCer = ".\src\ModVerify.CliApp\Resources\Certs\modverify-trust.cer"
267+
if (Test-Path $embeddedCer) {
268+
$embedded = [System.Security.Cryptography.X509Certificates.X509CertificateLoader]::LoadCertificate(
269+
[IO.File]::ReadAllBytes($embeddedCer))
270+
if ($embedded.Thumbprint -eq $rootCert.Thumbprint) {
271+
Write-Host "Embedded trust cert MATCHES the loaded root. OK."
272+
} else {
273+
Write-Warning "Embedded trust cert thumbprint DOES NOT MATCH the loaded root."
274+
}
275+
$embedded.Dispose()
276+
}
277+
278+
$rootCert.Dispose()
279+
Write-Host "Annual test PASSED."
280+
} catch {
281+
Write-Error "ANNUAL TEST FAILED. Recovery may be required (see section 5)."
282+
throw
283+
}
284+
```
285+
286+
If this fails (wrong passphrase, corrupted PFX, missing backups all gone) → go to
287+
section 5 immediately. **Do not** wait for the current intermediate to expire.
288+
289+
---
290+
291+
## 5. Catastrophic root rotation (recovery only)
292+
293+
**When:**
294+
- Root key lost (annual test failed across all backups).
295+
- Root key compromised (someone else has it).
296+
- Root cert within final year of its 20-year validity (unlikely concern for a long time).
297+
298+
**Effect:** Auto-update is **dead** for every deployed client until they manually
299+
reinstall. There is no graceful path. This is the failure mode the design accepts in
300+
exchange for not needing constant trust-store ceremonies.
301+
302+
### Procedure
303+
304+
1. **Generate a new root** offline (section 1, using a different filename
305+
`modverify-root-v2.pfx`).
306+
2. **Issue a first intermediate under the new root** (section 2, signed by the new root).
307+
3. **Update `src/ModVerify.CliApp/Resources/Certs/modverify-trust.cer`** to the new root's
308+
public cert. (Drop the old root entirely — the new root is the only trust anchor.)
309+
4. **Update GitHub Secrets** to the new intermediate's PFX/password.
310+
5. **Cut a release.** Auto-update is broken for all existing clients (they don't trust
311+
the new root). They will sit on their last installed version.
312+
6. **Announce widely** — release notes, README, every forum the userbase reads. Make
313+
clear that users must manually download from GitHub Releases to recover.
314+
7. **If the recovery is due to compromise (Scenario B)**: the malicious clients out there
315+
stay malicious until reinstalled. This is the irreducible blast radius.
316+
317+
### After
318+
319+
1. Resume normal operations under the new root.
320+
2. Treat the new root with the same custody discipline as the old one (section 1 "After").
321+
3. The old root is dead — destroy any remaining copies (overwrite, shred the printed
322+
backup, etc.).
323+
324+
---
325+
326+
## 6. CI intermediate compromise response
327+
328+
**Signs:** Unauthorized release in GitHub Actions history; signing-key leak alert;
329+
unexplained signing activity.
330+
331+
### Procedure
332+
333+
1. **Immediately rotate the intermediate** via section 2. Use a new passphrase.
334+
2. **Trigger a release** with a bumped version high enough to overtake any malicious
335+
release the attacker might publish (the rollback-rejection mechanism in
336+
`update-signing.md`*Deferred update resumption* refuses anything older).
337+
3. **Audit recent releases** since the suspected compromise window. Any unexpected
338+
release should be flagged and version-bumped over.
339+
4. **The compromised intermediate's manifests stay verifiable until its `notAfter`**
340+
you cannot revoke it mid-lifetime without infrastructure we don't have. Mitigation is
341+
the version-bump-and-overtake. The compromised intermediate will expire naturally on
342+
its original schedule.
343+
344+
### Why we don't bother with mid-lifetime revocation
345+
346+
A CRL or revocation list embedded in manifests is on the table per `update-signing.md`'s
347+
"Revocation" notes, but for ModVerify scale, the simpler model is: **short intermediate
348+
lifetimes mean compromise is bounded automatically**. Issue intermediates with a
349+
defensible lifetime (1 year typical) and accept that lifetime as the worst-case
350+
compromise window.
351+
352+
---
353+
354+
## Quick reference
355+
356+
| Operation | Section | Frequency | Where |
357+
|---|---|---|---|
358+
| Generate root | 1 | Once | Offline machine |
359+
| Issue intermediate | 2 | Every ~9-10 months | Offline machine |
360+
| Dev cert | 3 | Per `deploy-local.ps1` run | Local |
361+
| Annual root test | 4 | Once per year | Offline machine |
362+
| Root rotation | 5 | Never if everything works | Offline machine |
363+
| CI compromise response | 6 | Hopefully never | Section 2 + bump + ship |

0 commit comments

Comments
 (0)