diff --git a/README.md b/README.md index bc47d26..1485ac5 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,8 @@ export SB_KEY=/path/to/sb.key SB_CERT=/path/to/sb.crt task image PLATFORM=vmware STATEKEY=nodeid # or: task iso PLATFORM=vmware STATEKEY=nodeid ``` -Keep `SB_CERT` set for the whole run: `rootfs:build` stamps it into the image as the upgrade anchor, and `uki:sign` signs the UKI and writes the detached `.uki.sig` with the same key. A node only stages later images signed by that key, so losing the key means re-provisioning to change anchors. Enroll the certificate in firmware `db` (vSphere, firmware UI, `sbctl`, or `efitools`) to boot with Secure Boot on, or run with Secure Boot off and rely on the stamped anchor for upgrades. +> [!IMPORTANT] +> Keep `SB_CERT` set for the whole run: `rootfs:build` stamps it into the image as the upgrade anchor, and `uki:sign` signs the UKI and writes the detached `.uki.sig` with the same key. A node only stages later images signed by that key, so losing the key means re-provisioning to change anchors. Enroll the certificate in firmware `db` (vSphere, firmware UI, `sbctl`, or `efitools`) to boot with Secure Boot on, or run with Secure Boot off and rely on the stamped anchor for upgrades. Public release assets are unsigned, or a build recipe only. [`docs/secure-boot.md`](docs/secure-boot.md) is the full guide: key generation, enrollment, building, verifying with `sbverify` and openssl, upgrades, and key custody. @@ -85,7 +86,8 @@ Each `v*` tag attaches these to its GitHub Release, all built by `task iso:unsig | `cryptosctl-{linux,darwin}-{amd64,arm64}` | the static CLI, stamped with the release version | | `SHA256SUMS` | SHA-256 of every asset above | -They are for evaluation with Secure Boot off. A node installed from one cannot be upgraded in place (it has no anchor), so for real use build with your own key as above. `task image:unsigned` and `task iso:unsigned` reproduce the assets locally; they clear `SB_CERT` for `rootfs:build` and never sign, even if `SB_KEY`/`SB_CERT` are exported. +> [!IMPORTANT] +> They are for evaluation with Secure Boot off. A node installed from one cannot be upgraded in place (it has no anchor), so for real use build with your own key as above. `task image:unsigned` and `task iso:unsigned` reproduce the assets locally; they clear `SB_CERT` for `rootfs:build` and never sign, even if `SB_KEY`/`SB_CERT` are exported. ## 🤖 Continuous integration @@ -123,6 +125,9 @@ Issuing LDAPS and KDC certificates to Active Directory domain controllers, inclu Most `config apply` changes report `requires_reboot=true`. Restart the node through its orderly shutdown rather than a hypervisor hard reset: +> [!NOTE] +> `cryptosctl` runs on Linux and macOS today. A Windows build is coming. + ```sh cryptosctl --endpoint pki-root.example:443 reboot --confirm "Example Root CA G1" cryptosctl --endpoint pki-root.example:443 reboot --confirm "Example Root CA G1" --power-off diff --git a/build/README.md b/build/README.md index e3b51aa..02459c2 100644 --- a/build/README.md +++ b/build/README.md @@ -47,6 +47,7 @@ The build host needs the kernel build deps (`build-essential`, `bc`, `flex`, `cryptsetup`), and the UKI tooling: `systemd-ukify` + `systemd-boot-efi` (the EFI stub). See `.github/workflows/ci-image.yml` for the exact apt list. +> [!IMPORTANT] > **`ukify` needs the Python `pefile` module.** `systemd-ukify` does not depend > on it, so `uki:assemble` fails with `ModuleNotFoundError: No module named > 'pefile'` unless `pefile` is installed. On CI/system Python install the @@ -165,10 +166,11 @@ TPM at all: the state-partition LUKS key is derived from the SMBIOS product UUID stored on the encrypted state partition. `cryptosctl status` reports `TPM: UNAVAILABLE` on such a node so the weaker posture is never hidden. -**Dev tier only.** A UUID is not secret, so the Root key's confidentiality rests -on the UUID: an attacker with both the disk image and the node UUID can recover -the Root key. Use `nodeid` to run CryptOS where a vTPM is unavailable, never for -a CA guarding real trust. +> [!CAUTION] +> **Dev tier only.** A UUID is not secret, so the Root key's confidentiality rests +> on the UUID: an attacker with both the disk image and the node UUID can recover +> the Root key. Use `nodeid` to run CryptOS where a vTPM is unavailable, never for +> a CA guarding real trust. ## Machine config delivery @@ -186,12 +188,30 @@ config reaches a node exclusively via the install path: ## Maintenance install: operator workflow +> [!CAUTION] +> Security note: the maintenance API accepts unauthenticated clients by design +> (the Talos maintenance model). In this mode `config apply` erases the target +> disk and installs a config the caller supplies, then reboots into that +> configuration. Anyone who can reach the maintenance node on port 443 before the +> operator can therefore take over the node. Only expose a maintenance node on a +> trusted, isolated provisioning network, and complete the install before moving +> it onto a general network. + +> [!IMPORTANT] +> The `--insecure` flag disables server-certificate verification and sends no +> client identity. It must only be used against a maintenance endpoint; running +> it against an established node's mTLS port would succeed only if the server +> also accepts unauthenticated clients, which it does not. + When a bare-metal node boots the CryptOS UKI for the first time it has no state partition, so init enters maintenance mode and listens on a temporary gRPC endpoint (default port 443) with a self-signed server certificate and no client authentication. The operator sends the machine config from a workstation on the same network: +> [!NOTE] +> `cryptosctl` runs on Linux and macOS today. A Windows build is coming. + ```sh cryptosctl \ --insecure \ @@ -204,24 +224,14 @@ cryptosctl \ device (for example `/dev/sda` or `/dev/nvme0n1`). The maintenance node reads that field from the `ApplyConfig` RPC, partitions the disk, writes the UKI to the ESP, copies the config to the state partition, and reboots into the -installed system. A successful apply prints: - -``` -applied: generation=1 requires_reboot=true digest= -``` - -The `--insecure` flag disables server-certificate verification and sends no -client identity. It must only be used against a maintenance endpoint; running -it against an established node's mTLS port would succeed only if the server -also accepts unauthenticated clients, which it does not. - -Security note: the maintenance API accepts unauthenticated clients by design -(the Talos maintenance model). In this mode `config apply` erases the target -disk and installs a config the caller supplies, then reboots into that -configuration. Anyone who can reach the maintenance node on port 443 before the -operator can therefore take over the node. Only expose a maintenance node on a -trusted, isolated provisioning network, and complete the install before moving -it onto a general network. +installed system. + +> [!TIP] +> A successful apply prints: +> +> ```text +> applied: generation=1 requires_reboot=true digest= +> ``` ## Not covered here (separate issues) diff --git a/docs/acme.md b/docs/acme.md index 39bc7a6..33d30b5 100644 --- a/docs/acme.md +++ b/docs/acme.md @@ -5,6 +5,7 @@ of an operator moving every CSR by hand through `cryptosctl`. The usual clients all work: certbot, lego and acme.sh on Linux; win-acme, Posh-ACME and Certify The Web on Windows — with no AD CS anywhere in the chain. +> [!NOTE] > Scope: `http-01` only. `dns-01` and wildcard names are not implemented, and a > wildcard identifier is refused at order time rather than half-supported. @@ -45,10 +46,11 @@ pki: hmac_key_base64: <32+ bytes, base64url, no padding> ``` -`base_url` must be what clients dial, not what the node binds. Every URL the -server hands out is built from it, and every request carries a signature over -the URL it was sent to, so a `base_url` that does not match the front end -breaks every request rather than degrading quietly. +> [!IMPORTANT] +> `base_url` must be what clients dial, not what the node binds. Every URL the +> server hands out is built from it, and every request carries a signature over +> the URL it was sent to, so a `base_url` that does not match the front end +> breaks every request rather than degrading quietly. `http_port` is the origin port behind your TLS terminator. It defaults to 8555 and is deliberately not 80: port 80 belongs to the CRL/OCSP listener, which has @@ -60,10 +62,20 @@ Accounts require an External Account Binding by default. An open ACME endpoint on an internal CA is a broad grant, so the binding is what decides *who* may enrol, while the `http-01` challenge decides *what* they may enrol for. -```sh +**Linux / macOS** + +```bash head -c 32 /dev/urandom | basenc --base64url | tr -d '=' ``` +**Windows (PowerShell)** + +```powershell +$bytes = New-Object byte[] 32 +[System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes) +[Convert]::ToBase64String($bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_') +``` + Put the result in `hmac_key_base64` and hand the same value, with its `key_id`, to whoever runs the client. Keys shorter than 32 bytes are rejected: nothing rate-limits an offline guess against a captured binding. diff --git a/docs/active-directory.md b/docs/active-directory.md index bcffa8e..2af35c0 100644 --- a/docs/active-directory.md +++ b/docs/active-directory.md @@ -16,14 +16,15 @@ The profile decides every extension. The CSR contributes only the subject and the public key; SANs or EKUs requested in the CSR are ignored. So the names on a domain controller's certificate are whatever its profile lists. -Set `revocation_base_url` on the issuing node. Windows checks revocation on both -certificates, and a DC certificate whose CRL cannot be fetched fails -validation. With the base URL set, every certificate carries a CRL distribution -point at `/crl`, an AIA OCSP pointer at `/ocsp`, and an AIA -caIssuers pointer at `/ca.cer`, from which a client that trusts only the -root can fetch the issuing CA's certificate. Use an `http://` URL. Windows -fetches CRLs and AIA over plain HTTP, and HTTPS would make revocation checking -depend on another certificate. +> [!IMPORTANT] +> Set `revocation_base_url` on the issuing node. Windows checks revocation on both +> certificates, and a DC certificate whose CRL cannot be fetched fails +> validation. With the base URL set, every certificate carries a CRL distribution +> point at `/crl`, an AIA OCSP pointer at `/ocsp`, and an AIA +> caIssuers pointer at `/ca.cer`, from which a client that trusts only the +> root can fetch the issuing CA's certificate. Use an `http://` URL. Windows +> fetches CRLs and AIA over plain HTTP, and HTTPS would make revocation checking +> depend on another certificate. ```yaml pki: @@ -126,6 +127,9 @@ certificate name the DC on the command line instead: dns: [ad.example.org] # used only when no --dns is given ``` +> [!NOTE] +> `cryptosctl` runs on Linux and macOS today. A Windows build is coming. + ```sh cryptosctl ca issue-leaf \ --endpoint 192.0.2.10:443 \ @@ -182,8 +186,9 @@ certreq -new dc01-ldaps.inf dc01-ldaps.req certreq -new dc01-kdc.inf dc01-kdc.req ``` -`certreq -new` keeps the new key as a pending request in the computer's store. -The certificate must be accepted on the same DC. +> [!IMPORTANT] +> `certreq -new` keeps the new key as a pending request in the computer's store. +> The certificate must be accepted on the same DC. ## Issuing @@ -215,9 +220,10 @@ Check the extensions before installing: openssl x509 -in dc01-kdc.cer -noout -ext subjectAltName,extendedKeyUsage,authorityInfoAccess ``` -Expect `Signing KDC Response` and `Microsoft Smartcard Login` among the EKUs, -`othername: 1.3.6.1.5.2.2:` next to the DNS names, and the -`CA Issuers` URI under your `revocation_base_url`. +> [!TIP] +> Expect `Signing KDC Response` and `Microsoft Smartcard Login` among the EKUs, +> `othername: 1.3.6.1.5.2.2:` next to the DNS names, and the +> `CA Issuers` URI under your `revocation_base_url`. ## Trusting the chain @@ -248,9 +254,10 @@ certutil -store My certutil -verify -urlfetch dc01-kdc.cer ``` -`certutil -verify -urlfetch` fetches the CRL, OCSP and AIA URLs and should end -with no errors. `certreq -accept` fails with a chain error if the root is not -yet trusted on the DC. +> [!TIP] +> `certutil -verify -urlfetch` fetches the CRL, OCSP and AIA URLs and should end +> with no errors. `certreq -accept` fails with a chain error if the root is not +> yet trusted on the DC. To have AD DS load the LDAPS certificate without a reboot, apply an LDIF that sets `renewServerCertificate` on the rootDSE. `renew.ldf`: diff --git a/docs/certificate-profiles.md b/docs/certificate-profiles.md index 37ce549..44b1435 100644 --- a/docs/certificate-profiles.md +++ b/docs/certificate-profiles.md @@ -50,9 +50,12 @@ With `validity_policy: cap`, the default, the certificate is issued and ends at the issuer's notAfter. `cryptosctl` prints a warning on stderr, and the certificate on stdout is unchanged: -``` -WARNING: requested validity ends 2046-09-22; capped to issuer notAfter 2041-09-21 -``` +> [!TIP] +> Expected output on stderr when the cap applies: +> +> ```text +> WARNING: requested validity ends 2046-09-22; capped to issuer notAfter 2041-09-21 +> ``` The audit entry for the `IssueLeaf` or `SignSubordinateCSR` call records both dates as `requested_not_after` and `effective_not_after`. ACME and EST issue the @@ -63,9 +66,12 @@ With `validity_policy: reject`, the node refuses to issue instead. The call fails with `FailedPrecondition` and a message naming both dates, before the CA key is loaded: -``` -node: profile "platform-sub-ca" has validity_policy reject: requested validity ends 2046-09-22, after issuer notAfter 2041-09-21 -``` +> [!TIP] +> Expected output when the profile refuses the request: +> +> ```text +> node: profile "platform-sub-ca" has validity_policy reject: requested validity ends 2046-09-22, after issuer notAfter 2041-09-21 +> ``` Use `reject` where a shortened certificate would cause trouble later. One example is a platform CA whose renewal is planned around a fixed lifetime. @@ -73,9 +79,12 @@ example is a platform CA whose renewal is planned around a fixed lifetime. `cryptosctl config apply` also warns when a profile's `validity_days` already runs past the node's CA certificate, for example: -``` -WARNING: profile "platform-sub-ca": validity_days 7300 runs past this CA's notAfter 2041-09-21; its certificates will be capped to that date -``` +> [!TIP] +> Expected output from `config apply` when a profile outlives the CA: +> +> ```text +> WARNING: profile "platform-sub-ca": validity_days 7300 runs past this CA's notAfter 2041-09-21; its certificates will be capped to that date +> ``` This is a warning, not a validation error. The config is still applied, because every profile eventually crosses the line as the CA's remaining diff --git a/docs/est.md b/docs/est.md index 2c695a6..e9a88c0 100644 --- a/docs/est.md +++ b/docs/est.md @@ -8,6 +8,7 @@ SCEP leans on a shared secret that has to live on every device forever. Use EST where a fleet is provisioned once and then renews itself for years: network gear, appliances, anything with a factory or first-boot identity. +> [!NOTE] > Scope: `/cacerts`, `/simpleenroll`, `/simplereenroll` and `/csrattrs`. > Server-side key generation and the deferred-enrolment 202 flow are not > implemented. @@ -58,10 +59,11 @@ pki: password_sha256: <64 hex characters> ``` -The profile needs `client_auth` in `ext_key_usage` if the certificates it -issues are themselves going to re-enrol later: `simplereenroll` verifies the -presented certificate for client authentication, and one issued without that -usage cannot renew itself. +> [!IMPORTANT] +> The profile needs `client_auth` in `ext_key_usage` if the certificates it +> issues are themselves going to re-enrol later: `simplereenroll` verifies the +> presented certificate for client authentication, and one issued without that +> usage cannot renew itself. `hostnames` matters more here than elsewhere. Unlike the ACME listener, which sits behind your TLS terminator, this one terminates TLS itself, because @@ -85,17 +87,31 @@ exists to serve commonly top out at 1.2. ### Provisioning a simpleenroll credential -The config holds the SHA-256 of the password, not the password, so a running -configuration never contains a live credential. That is only safe because the -password has to be generated rather than chosen: a digest of a memorable word -falls to a dictionary in seconds. +> [!CAUTION] +> The config holds the SHA-256 of the password, not the password, so a running +> configuration never contains a live credential. That is only safe because the +> password has to be generated rather than chosen: a digest of a memorable word +> falls to a dictionary in seconds. -```sh +**Linux / macOS** + +```bash PASSWORD=$(head -c 24 /dev/urandom | basenc --base64url | tr -d '=') printf '%s' "$PASSWORD" | sha256sum | cut -d' ' -f1 echo "password: $PASSWORD" ``` +**Windows (PowerShell)** + +```powershell +$bytes = New-Object byte[] 24 +[System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes) +$PASSWORD = [Convert]::ToBase64String($bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_') +$sha = [System.Security.Cryptography.SHA256]::Create() +-join ($sha.ComputeHash([System.Text.Encoding]::ASCII.GetBytes($PASSWORD)) | ForEach-Object { $_.ToString('x2') }) +"password: $PASSWORD" +``` + Put the digest in `password_sha256` and give the password to whoever runs the client. Keep it out of the config. @@ -106,17 +122,29 @@ trust this CA has to be able to fetch it, and the certificates it returns are public by definition. On first contact a client either trusts the response explicitly out of band or already holds the CA from provisioning. -```sh +**Linux / macOS** + +```bash curl -s https://est.example.org:8443/.well-known/est/cacerts \ | base64 -d \ | openssl pkcs7 -inform DER -print_certs -noout ``` +**Windows (PowerShell)** + +```powershell +curl.exe -s https://est.example.org:8443/.well-known/est/cacerts -o cacerts.b64 +openssl base64 -d -A -in cacerts.b64 -out cacerts.p7b +openssl pkcs7 -inform DER -in cacerts.p7b -print_certs -noout +``` + ## Enrolling `simpleenroll`, with a provisioned credential: -```sh +**Linux / macOS** + +```bash openssl req -new -newkey ec -pkeyopt ec_paramgen_curve:P-384 -nodes \ -keyout switch01.key -subj "/CN=switch01.example.org" \ -addext "subjectAltName=DNS:switch01.example.org" -outform DER -out switch01.csr @@ -130,6 +158,25 @@ curl -s --cacert ca.pem \ | base64 -d | openssl pkcs7 -inform DER -print_certs -out switch01.pem ``` +**Windows (PowerShell)** + +```powershell +openssl req -new -newkey ec -pkeyopt ec_paramgen_curve:P-384 -nodes ` + -keyout switch01.key -subj "/CN=switch01.example.org" ` + -addext "subjectAltName=DNS:switch01.example.org" -outform DER -out switch01.csr + +openssl base64 -A -in switch01.csr -out switch01.csr.b64 +curl.exe -s --cacert ca.pem ` + -u "switch-fleet:$PASSWORD" ` + -H "Content-Type: application/pkcs10" ` + -H "Content-Transfer-Encoding: base64" ` + --data-binary "@switch01.csr.b64" ` + https://est.example.org:8443/.well-known/est/simpleenroll ` + -o switch01.b64 +openssl base64 -d -A -in switch01.b64 -out switch01.p7b +openssl pkcs7 -inform DER -in switch01.p7b -print_certs -out switch01.pem +``` + `simplereenroll`, authenticated by the certificate being replaced: ```sh diff --git a/docs/image-upgrade.md b/docs/image-upgrade.md index 7b4b422..4b737ac 100644 --- a/docs/image-upgrade.md +++ b/docs/image-upgrade.md @@ -37,6 +37,17 @@ reinstall. ## The procedure +> [!WARNING] +> **This has not been exercised on production hardware yet.** The verification, +> slot management, RPC and CLI paths are covered by tests; the actual +> `mount`/reboot sequence on a real ESP is not something a test can cover. +> The same goes for the TPM reseal: it is tested against a TPM simulator and +> a model of the stub's measurement, not yet across a real reboot on a vTPM. +> Do the first run on a node you can reach physically. + +> [!NOTE] +> `cryptosctl` runs on Linux and macOS today. A Windows build is coming. + The commands below use the default trust file, `~/.cryptos/trust.crt`. It has to hold the node's current self-signed management certificate, and the endpoint has to be the IP that certificate names. A DNS name like the one shown @@ -50,18 +61,27 @@ node, and the reboot replaces that certificate. cryptosctl --endpoint pki-root.example:443 image status ``` -``` -Running version: v1.4.0 -Running image: 9f2c... -Next boot image: 9f2c... -Previous image: (none retained) -Reboot pending: no -``` - -`Running image` and `Next boot image` being equal means nothing is staged. +> [!TIP] +> Expected output, for a node with nothing staged: +> +> ```text +> Running version: v1.4.0 +> Running image: 9f2c... +> Next boot image: 9f2c... +> Previous image: (none retained) +> Reboot pending: no +> ``` +> +> `Running image` and `Next boot image` being equal means nothing is staged. ### 2. Stage the new image +> [!CAUTION] +> **A TPM node running an image from before the reseal cannot be upgraded +> in place.** Staging runs on the old image, and an old image does not reseal +> the key, so the new image would boot unable to unlock the state partition. +> Reinstall such a node instead. See "TPM-backed nodes" below. + ```sh cryptosctl --endpoint pki-root.example:443 image stage \ --image build/out/cryptos-amd64.uki @@ -79,15 +99,24 @@ pending. The node is still running the old image. ### 3. Activate, in the window +> [!WARNING] +> This reboots the node through the same orderly shutdown as +> `cryptosctl reboot`: every certificate operation that depends on it is +> unavailable until it comes back. + +> [!WARNING] +> **An image that does not boot at all is not recoverable over the network.** +> The retained image is bootable, but selecting it means reaching the node, +> which is the thing that is down. Test a new image on a non-production node +> first. + ```sh cryptosctl --endpoint pki-root.example:443 image activate \ --confirm "Example Root CA G1" ``` `--confirm` must be the node's CA common name, the same echo the reset verbs -require. This reboots the node through the same orderly shutdown as -`cryptosctl reboot`: every certificate operation that depends on it is -unavailable until it comes back. +require. The node reads its CA common name when the call arrives, so a CA certificate installed earlier in the same boot (a subordinate's `submit-subordinate-cert`, @@ -95,17 +124,19 @@ or the ceremony) is accepted without a reboot first. A node that has no CA certificate yet refuses with `FailedPrecondition` ("node has no CA identity yet"); a wrong common name is refused with `PermissionDenied`. -Confirm afterwards. The node came back with a new management certificate, so -the pin you used before the reboot no longer matches. Fetch the current one -first, as described in [`management-trust.md`](management-trust.md), or the -next call fails with `x509: certificate signed by unknown authority`: +> [!IMPORTANT] +> Confirm afterwards. The node came back with a new management certificate, so +> the pin you used before the reboot no longer matches. Fetch the current one +> first, as described in [`management-trust.md`](management-trust.md), or the +> next call fails with `x509: certificate signed by unknown authority`: ```sh cryptosctl --endpoint pki-root.example:443 image status ``` -`Running version` should be the new one, the two digests should agree again, -and `Previous image` should now name the image you upgraded from. +> [!TIP] +> `Running version` should be the new one, the two digests should agree again, +> and `Previous image` should now name the image you upgraded from. ### 4. If it went badly @@ -158,22 +189,8 @@ poor trade against PKCS#1 v1.5 over a SHA-256 digest. against the anchor of the running one. Lose the key and the only way to a new anchor is a re-provision. See the key custody section of [`secure-boot.md`](secure-boot.md). -- **A TPM node running an image from before the reseal cannot be upgraded - in place.** Staging runs on the old image, and an old image does not reseal - the key, so the new image would boot unable to unlock the state partition. - Reinstall such a node instead. See "TPM-backed nodes" below. - **Only one previous image is retained.** Two upgrades in a row leave you able to roll back one. -- **An image that does not boot at all is not recoverable over the network.** - The retained image is bootable, but selecting it means reaching the node, - which is the thing that is down. Test a new image on a non-production node - first. -- **This has not been exercised on production hardware yet.** The verification, - slot management, RPC and CLI paths are covered by tests; the actual - `mount`/reboot sequence on a real ESP is not something a test can cover. - The same goes for the TPM reseal: it is tested against a TPM simulator and - a model of the stub's measurement, not yet across a real reboot on a vTPM. - Do the first run on a node you can reach physically. ## TPM-backed nodes diff --git a/docs/issued-certificates.md b/docs/issued-certificates.md index 734c621..effe99a 100644 --- a/docs/issued-certificates.md +++ b/docs/issued-certificates.md @@ -5,6 +5,9 @@ serial. `cryptosctl` reads that set, fetches a certificate back from it, and revokes from it. All three verbs need the bootstrap admin client certificate over mTLS, the same as every other `ca` verb. +> [!NOTE] +> `cryptosctl` runs on Linux and macOS today. A Windows build is coming. + ## List what a node issued ```sh @@ -24,9 +27,12 @@ stdout gets the certificate as PEM, followed by the node's chain from the issuing CA up to the root, so the output can go straight to a file. stderr gets the status: -```text -status: revoked (revoked at 2026-03-04T05:06:07Z) -``` +> [!TIP] +> The status line on stderr, here for a revoked certificate: +> +> ```text +> status: revoked (revoked at 2026-03-04T05:06:07Z) +> ``` The status is `valid`, `revoked` or `expired`. A revoked certificate is still returned, and its revocation time is included. If a certificate is both @@ -51,6 +57,11 @@ Errors: ## Revoke +> [!CAUTION] +> Revocation cannot be undone. The node has no way to take a serial back off +> the revoked list, and revoking it again returns the original record, +> reason code included. Check the serial with `ca get-issued` first. + ```sh cryptosctl --endpoint pki-issuing.example:443 ca revoke --serial 4f1a09c2 --reason 1 cryptosctl --endpoint pki-issuing.example:443 ca revocations diff --git a/docs/management-trust.md b/docs/management-trust.md index 09ae47d..24f6721 100644 --- a/docs/management-trust.md +++ b/docs/management-trust.md @@ -20,9 +20,15 @@ the node's CA. At every boot the node generates a new key and a new So `--trust` has to be **the node's current management certificate itself**, pinned. Pointing it at a CA certificate fails like this: -``` -x509: certificate signed by unknown authority -``` +> [!TIP] +> This output means `--trust` does not hold the node's current management +> certificate: +> +> ```text +> x509: certificate signed by unknown authority +> ``` +> +> Fetch the current certificate, as in [Getting the current certificate](#getting-the-current-certificate), then retry. A pin taken before a reboot fails the same way afterwards. Any reboot does it: a planned restart, `image activate`, a power event, a hypervisor migration that @@ -38,6 +44,9 @@ dashboard shows a **Mgmt SHA-256** line: the SHA-256 of the management certificate this boot, in groups of four hex digits. It changes on every boot, like the certificate. +> [!NOTE] +> `cryptosctl` runs on Linux and macOS today. A Windows build is coming. + Then fetch the certificate and check it against that value in one step: ```sh @@ -59,19 +68,30 @@ nothing. Without `--expect-sha256` it saves whatever it received and says the pin is not verified; compare the printed SHA-256 with the console before you rely on it. -Check that the subject alternative names are the node's IP and `localhost`, -and that the certificate is self-signed (subject and issuer match). Then pass -`--trust node-trust.pem` on each call, or fetch straight into the default path. +> [!TIP] +> Check that the subject alternative names are the node's IP and `localhost`, +> and that the certificate is self-signed (subject and issuer match). Then pass +> `--trust node-trust.pem` on each call, or fetch straight into the default path. Without `cryptosctl` at hand, openssl gets the same certificate and fingerprint: -```sh +**Linux / macOS** + +```bash openssl s_client -connect 192.0.2.10:443 -servername 192.0.2.10 /dev/null \ | openssl x509 -outform PEM > node-trust.pem openssl x509 -in node-trust.pem -noout -subject -issuer -enddate -fingerprint -sha256 -ext subjectAltName ``` +**Windows (PowerShell)** + +```powershell +'Q' | openssl s_client -connect 192.0.2.10:443 -servername 192.0.2.10 2>$null | + openssl x509 -outform PEM -out node-trust.pem +openssl x509 -in node-trust.pem -noout -subject -issuer -enddate -fingerprint -sha256 -ext subjectAltName +``` + ## Address the node by IP The certificate has no DNS names. Hostname verification only passes when the @@ -92,9 +112,10 @@ certificate you saved is the one the node generated this boot, so later calls reach your node. Reading the console needs access to it (the physical screen or the hypervisor console), which is the out-of-band channel the check relies on. -A fetch you did not check is trust on first use. The pin tells you that later -calls reach the same endpoint that answered the fetch. It does not tell you -that endpoint is your node. +> [!CAUTION] +> A fetch you did not check is trust on first use. The pin tells you that later +> calls reach the same endpoint that answered the fetch. It does not tell you +> that endpoint is your node. For an unchecked pin, what limits the damage is the mutual TLS. An impostor that answered the fetch still does not hold your admin key, so it cannot relay diff --git a/docs/secure-boot.md b/docs/secure-boot.md index 90301c2..f07e179 100644 --- a/docs/secure-boot.md +++ b/docs/secure-boot.md @@ -6,6 +6,7 @@ certificate on the machines you run. The same certificate becomes the node's upgrade anchor, so the key that makes an image bootable is also the only key that can replace it later. +> [!IMPORTANT] > **Public release assets are unsigned, or a build recipe only.** Nothing the > project publishes is signed by, carries, or trusts a key the project > generated. A prebuilt image is for evaluation with Secure Boot off; it has no @@ -58,9 +59,10 @@ openssl req -new -x509 -newkey rsa:2048 -sha256 -noenc -days 3650 \ openssl x509 -in sb.crt -outform DER -out sb.der ``` -`-noenc` needs OpenSSL 3.0 or later; on 1.1.1 use `-nodes`. The key is written -unencrypted because `sbsign` and `sign.sh` read it non-interactively; protect it -with file permissions and storage instead (see "Key custody" below). +> [!CAUTION] +> `-noenc` needs OpenSSL 3.0 or later; on 1.1.1 use `-nodes`. The key is written +> unencrypted because `sbsign` and `sign.sh` read it non-interactively; protect it +> with file permissions and storage instead (see "Key custody" below). The extensions match what `cryptos-sbkey` writes: a self-signed certificate with the code-signing extended key usage. @@ -121,11 +123,12 @@ changes is whether the firmware also checks the image at every boot. | Upgrades must be signed by your key | yes | yes (stamped anchor) | | Setup needed per machine | enroll `sb.der` in `db` | none | -Decide **before installing a node**. With the default `STATEKEY=tpm`, the state -partition key is sealed to PCR 7, which measures the Secure Boot state and the -`db` contents. Turning Secure Boot on or off, or changing `db`, after the node -is installed means the state key no longer unseals. The `STATEKEY=nodeid` -variant does not use the TPM and is not affected. +> [!CAUTION] +> Decide **before installing a node**. With the default `STATEKEY=tpm`, the state +> partition key is sealed to PCR 7, which measures the Secure Boot state and the +> `db` contents. Turning Secure Boot on or off, or changing `db`, after the node +> is installed means the state key no longer unseals. The `STATEKEY=nodeid` +> variant does not use the TPM and is not affected. ## 3. Enroll the certificate (Secure Boot on) @@ -154,8 +157,9 @@ certificates in `db`, which do not cover your image. To add yours: 4. Power off, remove `uefi.allowAuthBypass` and the key disk, and confirm **Secure Boot** is still enabled under **VM Options > Boot Options**. -Do this before the first boot of the CryptOS installer image, for the reason -given in step 2 above. +> [!IMPORTANT] +> Do this before the first boot of the CryptOS installer image, for the reason +> given in step 2 above. ### Bare metal: firmware setup UI @@ -173,14 +177,15 @@ Most enterprise firmware accepts a raw DER (`.der` / `.cer`) file here. CryptOS has no shell, so run this from a live Linux USB on the target, with the firmware in Setup Mode: +> [!CAUTION] +> Drop `--append` only if you mean to replace the platform keys entirely. + ```bash sbctl status # confirm Setup Mode sbctl import-keys --db-cert ./sb.der # add your cert to db sbctl enroll-keys --append # --append keeps the existing keys ``` -Drop `--append` only if you mean to replace the platform keys entirely. - ### Scripted or air-gapped: `efitools` If you own PK and KEK, build a signed `db` update: @@ -216,17 +221,18 @@ Drop `STATEKEY=nodeid` (or set `STATEKEY=tpm`) for the TPM-backed image; see [`build/README.md`](../build/README.md#statekey-the-tpm-less-nodeid-variant) for when `nodeid` is appropriate. -**Keep `SB_CERT` set for the whole run.** The certificate is read twice, at -different steps: - -- `rootfs:build` stamps it into `/init` as the upgrade anchor, if it is set. -- `uki:sign` signs the finished UKI with `SB_KEY` / `SB_CERT`, and fails if - either is missing. - -Running the steps separately with `SB_CERT` unset during `rootfs:build` gives -you a correctly signed image with **no** anchor: it boots, but a node installed -from it serves the image upgrade RPCs as `Unimplemented` and can only be -replaced by a reinstall. Exporting both variables once, as above, avoids that. +> [!IMPORTANT] +> **Keep `SB_CERT` set for the whole run.** The certificate is read twice, at +> different steps: +> +> - `rootfs:build` stamps it into `/init` as the upgrade anchor, if it is set. +> - `uki:sign` signs the finished UKI with `SB_KEY` / `SB_CERT`, and fails if +> either is missing. +> +> Running the steps separately with `SB_CERT` unset during `rootfs:build` gives +> you a correctly signed image with **no** anchor: it boots, but a node installed +> from it serves the image upgrade RPCs as `Unimplemented` and can only be +> replaced by a reinstall. Exporting both variables once, as above, avoids that. Outputs in `build/out/`: @@ -266,7 +272,9 @@ grep -q -a -F "$(openssl x509 -in "$SB_CERT" -outform DER | base64 -w0)" \ build/.work/rootfs-amd64/init && echo "anchor stamped" ``` -No output means the build carries no anchor. +> [!TIP] +> Expected output: `anchor stamped`. +> No output means the build carries no anchor. On the target, with Secure Boot on, the machine should boot the image. If the firmware refuses it, your certificate is not in `db`, or the image was signed @@ -279,6 +287,9 @@ A node accepts a new image only if its `.sig` verifies against the anchor the and `SB_CERT`, then stage and activate it as described in [`image-upgrade.md`](image-upgrade.md): +> [!NOTE] +> `cryptosctl` runs on Linux and macOS today. A Windows build is coming. + ```bash cryptosctl --endpoint pki-root.example.org:443 image stage \ --image build/out/cryptos-amd64.uki @@ -322,23 +333,26 @@ grep -q -a -E 'MII[A-Za-z0-9+/]{400,}' build/.work/rootfs-amd64/init \ sbverify --list build/out/cryptos-amd64.uki.unsigned # "No signature table present" ``` -Signing an unsigned UKI afterwards (`task uki:sign` with your key) makes it -bootable with Secure Boot on, but it still has no anchor: the anchor is compiled -into `/init` during `rootfs:build`, before the UKI exists. For a node you want -to upgrade in place, build from source with `SB_CERT` set as in step 4. +> [!IMPORTANT] +> Signing an unsigned UKI afterwards (`task uki:sign` with your key) makes it +> bootable with Secure Boot on, but it still has no anchor: the anchor is compiled +> into `/init` during `rootfs:build`, before the UKI exists. For a node you want +> to upgrade in place, build from source with `SB_CERT` set as in step 4. ## Key custody The key is the only thing that can produce an image your nodes will accept. -- **Losing the key means re-provisioning to change anchors.** A node checks the - next image against the anchor compiled into the image it is running, and - nothing on the node can replace that anchor. Without the key you cannot sign - an image it will stage, so the only way onto a new key is a reinstall, which - reformats the state partition and destroys the CA key. -- **A leaked key lets anyone with admin access to a node install an image of - their choosing**, and, with Secure Boot on, boot it on any machine that - trusts your certificate. Treat it like a CA key. +> [!CAUTION] +> - **Losing the key means re-provisioning to change anchors.** A node checks the +> next image against the anchor compiled into the image it is running, and +> nothing on the node can replace that anchor. Without the key you cannot sign +> an image it will stage, so the only way onto a new key is a reinstall, which +> reformats the state partition and destroys the CA key. +> - **A leaked key lets anyone with admin access to a node install an image of +> their choosing**, and, with Secure Boot on, boot it on any machine that +> trusts your certificate. Treat it like a CA key. + - Keep it offline when you are not building: an encrypted backup in at least two places, and the working copy on a build host you control, readable only by the account that runs the build. @@ -347,6 +361,12 @@ The key is the only thing that can produce an image your nodes will accept. ## Rotation +> [!CAUTION] +> Plan it as a physically attended, fleet-wide operation. On a `STATEKEY=tpm` node the `db` change alters PCR 7, +> which the state key is sealed to, so on those nodes a rotation is a +> re-provision today. If the old key was ever exposed, add its certificate to +> `dbx`. + CryptOS has no remote `db` update path. Moving to a new key means enrolling the new certificate in every machine's firmware, then building the next image with the new key and certificate so the new certificate becomes its anchor. The @@ -358,8 +378,3 @@ this; replace the `.sig` by hand after the build: openssl dgst -sha256 -sign /path/to/old/sb.key \ -out build/out/cryptos-amd64.uki.sig build/out/cryptos-amd64.uki ``` - -Plan it as a physically attended, fleet-wide operation. On a `STATEKEY=tpm` node the `db` change alters PCR 7, -which the state key is sealed to, so on those nodes a rotation is a -re-provision today. If the old key was ever exposed, add its certificate to -`dbx`. diff --git a/docs/subordinate-recertify.md b/docs/subordinate-recertify.md index 4e49199..d611296 100644 --- a/docs/subordinate-recertify.md +++ b/docs/subordinate-recertify.md @@ -49,6 +49,9 @@ The commands below abbreviate the global connection flags (`--endpoint`, [`management-trust.md`](management-trust.md) for pinning a node's management certificate. +> [!NOTE] +> `cryptosctl` runs on Linux and macOS today. A Windows build is coming. + **1. Configure the parent's revocation base URL.** Add `pki.revocation_base_url` to the parent's machine config and apply it: @@ -62,9 +65,11 @@ revocation preflight only at boot. Reboot the parent. Until the preflight passes, `sign-subordinate` refuses with `FailedPrecondition`: the base URL host must resolve, and `/crl`, `/ocsp` and `/ca.cer` must answer. The parent logs `revocation preflight: ok` once it passes, and re-checks every -30 seconds. Set -`allow_unverified_revocation_url` only if you accept pointers that may never -resolve. They are permanent in every certificate signed while it is set. +30 seconds. + +> [!CAUTION] +> Set `allow_unverified_revocation_url` only if you accept pointers that may +> never resolve. They are permanent in every certificate signed while it is set. **2. Fetch the renewal CSR from the intermediate.** @@ -119,8 +124,9 @@ openssl verify -CAfile root.pem -untrusted sub-renewed-chain.pem sub-renewed-cha openssl verify -CAfile root.pem -untrusted sub-renewed-chain.pem existing-leaf.pem ``` -Expect `/crl` under the CRL distribution points, and `OCSP - URI:/ocsp` -and `CA Issuers - URI:/ca.cer` under Authority Information Access. +> [!TIP] +> Expect `/crl` under the CRL distribution points, and `OCSP - URI:/ocsp` +> and `CA Issuers - URI:/ca.cer` under Authority Information Access. ## After the swap diff --git a/docs/vmca-subordination.md b/docs/vmca-subordination.md index f0936e7..218a753 100644 --- a/docs/vmca-subordination.md +++ b/docs/vmca-subordination.md @@ -21,10 +21,11 @@ it must hold an RSA key, and so must every CA above it. An RSA intermediate under an ECDSA root does not work: the intermediate's own certificate carries an `ecdsa-with-SHA384` signature and appears in the chain vCenter verifies. -**This fails silently at signing time.** CryptOS will happily certify an RSA -subject key from an ECDSA CA and hand you a certificate; vCenter refuses it at -import, after `certificate-manager` has started. `TestVMCASubordination_UnderAnECDSACARejected` -exists to pin that behaviour so it is not rediscovered the hard way. +> [!WARNING] +> **This fails silently at signing time.** CryptOS will happily certify an RSA +> subject key from an ECDSA CA and hand you a certificate; vCenter refuses it at +> import, after `certificate-manager` has started. `TestVMCASubordination_UnderAnECDSACARejected` +> exists to pin that behaviour so it is not rediscovered the hard way. An ECDSA-rooted fleet therefore needs a **separate RSA hierarchy** for this, not a re-key of the existing one. @@ -77,21 +78,23 @@ ahead of time when a profile's `validity_days` already runs past the CA. Set `validity_policy: reject` on the profile to refuse instead of capping. See [`certificate-profiles.md`](certificate-profiles.md#validity-and-the-issuing-cas-notafter). -**Set `revocation_base_url` before signing.** It is what stamps revocation -pointers onto issued certificates: a CRL distribution point at `/crl`, an -AIA OCSP pointer at `/ocsp`, and an AIA caIssuers pointer at -`/ca.cer`, where the node serves its own CA certificate (DER, -`application/pkix-cert`) so a client that holds only the root can fetch the -intermediate and build the chain. With it empty, the VMCA certificate is issued -with none of them and nothing can check whether it has been revoked; -a certificate already issued cannot gain them later without being re-signed. When -it is set, signing fails closed if the node's revocation preflight is not passing -(the URL does not resolve, or `/crl`, `/ocsp` or `/ca.cer` is unreachable), unless -`allow_unverified_revocation_url: true` is set. - -**Give the node a resolver when the URL names a host.** The preflight resolves -the `revocation_base_url` host from the node itself, so the node needs DNS -servers that resolve that name. Declare them in the machine config: +> [!IMPORTANT] +> **Set `revocation_base_url` before signing.** It is what stamps revocation +> pointers onto issued certificates: a CRL distribution point at `/crl`, an +> AIA OCSP pointer at `/ocsp`, and an AIA caIssuers pointer at +> `/ca.cer`, where the node serves its own CA certificate (DER, +> `application/pkix-cert`) so a client that holds only the root can fetch the +> intermediate and build the chain. With it empty, the VMCA certificate is issued +> with none of them and nothing can check whether it has been revoked; +> a certificate already issued cannot gain them later without being re-signed. When +> it is set, signing fails closed if the node's revocation preflight is not passing +> (the URL does not resolve, or `/crl`, `/ocsp` or `/ca.cer` is unreachable), unless +> `allow_unverified_revocation_url: true` is set. + +> [!IMPORTANT] +> **Give the node a resolver when the URL names a host.** The preflight resolves +> the `revocation_base_url` host from the node itself, so the node needs DNS +> servers that resolve that name. Declare them in the machine config: ```yaml network: @@ -140,13 +143,29 @@ to implement RSA-2048, though, and many parts stop there. have it emit its CSR. Copy the CSR off the appliance, then check its key size before going further: -```sh +> [!IMPORTANT] +> Do not assume the key size; the tooling on the appliance can produce a +> smaller key depending on how it is invoked. + +**Linux / macOS** + +```bash openssl req -in vmca.csr -noout -text | grep Public-Key ``` -The node refuses any RSA subject key below 3072 bits, so anything smaller than -`(3072 bit)` is rejected at signing. Do not assume the key size; the tooling on -the appliance can produce a smaller key depending on how it is invoked. +**Windows (PowerShell)** + +```powershell +openssl req -in vmca.csr -noout -text | Select-String -CaseSensitive Public-Key +``` + +> [!TIP] +> The output is the size of VMCA's key. Expected output: `Public-Key: (3072 bit)` +> or larger. +> +> The node refuses any RSA subject key below 3072 bits, so anything smaller than +> `(3072 bit)` is rejected at signing. Stop and generate a new request on +> vCenter if the key is smaller. **2. Sign it with the CryptOS CA.** From an operator workstation with an admin credential for the signing node, first pin the node's **current** management @@ -155,6 +174,9 @@ not verify it and a pin taken before the node's last reboot no longer works. Fetch it after the node is up, using the node's management IP, and check it against the **Mgmt SHA-256** the node's console shows: +> [!NOTE] +> `cryptosctl` runs on Linux and macOS today. A Windows build is coming. + ```sh cryptosctl --endpoint 192.0.2.10:443 --trust node-trust.pem \ trust fetch --expect-sha256 "" @@ -188,23 +210,34 @@ because of step 3: a certificate that verifies against your root came from your CA, whoever answered the connection. See [`management-trust.md`](management-trust.md) for the details. -The output is leaf-first: the new VMCA certificate followed by the certificate of -the CA that signed it. **The root is not included.** When the signing node is an -intermediate, the output is VMCA plus that intermediate and nothing above it. -`certificate-manager` needs the full chain, so append the rest of the chain up to -and including the root yourself: +> [!IMPORTANT] +> The output is leaf-first: the new VMCA certificate followed by the certificate of +> the CA that signed it. **The root is not included.** When the signing node is an +> intermediate, the output is VMCA plus that intermediate and nothing above it. +> `certificate-manager` needs the full chain, so append the rest of the chain up to +> and including the root yourself: -```sh +**Linux / macOS** + +```bash cat vmca-chain.pem root.pem > vmca-fullchain.pem ``` +**Windows (PowerShell)** + +```powershell +Get-Content vmca-chain.pem, root.pem | Set-Content vmca-fullchain.pem -Encoding ascii +``` + Under a deeper hierarchy, append each missing CA certificate in order, leaf to root, before the root. **3. Verify before importing.** Importing is the disruptive step, so check the result first: -```sh +**Linux / macOS** + +```bash # the VMCA certificate (the first one in the file) openssl x509 -in vmca-fullchain.pem -noout -text | grep -E 'Signature Algorithm|Not After|CA:|Key Usage|CRL Distribution|OCSP|CA Issuers' -A1 # the signature algorithm of every certificate in the chain @@ -213,17 +246,31 @@ openssl crl2pkcs7 -nocrl -certfile vmca-fullchain.pem | openssl pkcs7 -print_cer openssl verify -CAfile root.pem -untrusted vmca-chain.pem vmca-chain.pem ``` -Expect a `sha256WithRSAEncryption` or `sha384WithRSAEncryption` signature, -a `Not After` no later than the signing CA's own, -`CA:TRUE, pathlen:0`, both `Certificate Sign` and `CRL Sign`, and the CRL -distribution point, OCSP URL and CA Issuers URL under your `revocation_base_url`. An -`ecdsa-with-SHA384` signature on any certificate in the chain means the hierarchy -is not RSA end to end and vCenter will refuse the import. +**Windows (PowerShell)** + +```powershell +# the VMCA certificate (the first one in the file) +openssl x509 -in vmca-fullchain.pem -noout -text | Select-String -CaseSensitive -Pattern 'Signature Algorithm|Not After|CA:|Key Usage|CRL Distribution|OCSP|CA Issuers' -Context 0,1 +# the signature algorithm of every certificate in the chain +openssl crl2pkcs7 -nocrl -certfile vmca-fullchain.pem | openssl pkcs7 -print_certs -text -noout | Select-String -CaseSensitive 'Signature Algorithm' +# the chain verifies to your root +openssl verify -CAfile root.pem -untrusted vmca-chain.pem vmca-chain.pem +``` + +> [!TIP] +> Expect a `sha256WithRSAEncryption` or `sha384WithRSAEncryption` signature, +> a `Not After` no later than the signing CA's own, +> `CA:TRUE, pathlen:0`, both `Certificate Sign` and `CRL Sign`, and the CRL +> distribution point, OCSP URL and CA Issuers URL under your `revocation_base_url`. An +> `ecdsa-with-SHA384` signature on any certificate in the chain means the hierarchy +> is not RSA end to end and vCenter will refuse the import. + +> [!WARNING] +> vCenter restarts its services and reissues every certificate it had issued. **4. Import into vCenter** with `certificate-manager`, option 2 ("Replace VMCA Root certificate with Custom Signing Certificate"), giving it the full chain from -step 2 (`vmca-fullchain.pem`). vCenter restarts its services and reissues every -certificate it had issued. +step 2 (`vmca-fullchain.pem`). ## Limits worth knowing before you start