Skip to content

Commit a4b9bae

Browse files
authored
feat(ukisign): sign an unsigned ISO with cryptosctl image sign (#384)
cryptosctl image sign now takes an unsigned ISO as well as a UKI. It signs the UKI inside it the same way and writes it back over every copy, the El Torito EFI boot image's BOOTX64.EFI and the ISO 9660 file the installer copies, in pure Go with no root and no xorriso. The signed UKI and its .sig are written beside the ISO for staging upgrades. Refs CryptOS-PKI/cryptos-appliance#83 Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> Co-authored-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com>
1 parent 01cf9e5 commit a4b9bae

12 files changed

Lines changed: 2463 additions & 13 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ task e2e:kind # Linux + docker: cert-manager in kind gets a certificate over
7575

7676
This repo builds no bootable image. The hardened kernel, SquashFS rootfs, UKI assembly, Secure Boot signing, the installer ISO and the QEMU + `swtpm` integration suites all live in [`cryptos-appliance`](https://github.com/CryptOS-PKI/cryptos-appliance), which requires this module at a pinned version and builds `init`, `cryptosctl` and the console by import path. See that repo's README and `docs/secure-boot.md` for building, signing and Secure Boot key custody.
7777

78-
The release assets are unsigned and carry no upgrade anchor. `cryptosctl image sign --image <unsigned.uki> --key <key.pem> --cert <cert.pem>` signs one with your own Secure Boot key and adds your certificate as its upgrade anchor, so a node installed from it takes signed in-place upgrades. [`docs/image-sign.md`](docs/image-sign.md) covers it.
78+
The release assets are unsigned and carry no upgrade anchor. `cryptosctl image sign --image <unsigned.uki> --key <key.pem> --cert <cert.pem>` signs one with your own Secure Boot key and adds your certificate as its upgrade anchor, so a node installed from it takes signed in-place upgrades. Given an unsigned ISO, it signs the UKI inside it and writes a signed ISO. [`docs/image-sign.md`](docs/image-sign.md) covers it.
7979

8080
## 🤖 Continuous integration
8181

‎cmd/cryptosctl/image.go‎

Lines changed: 65 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ limitations under the License.
2121
// the state partition.
2222

2323
import (
24+
"crypto"
2425
"crypto/sha256"
2526
"crypto/x509"
2627
"encoding/hex"
@@ -32,6 +33,7 @@ import (
3233
"github.com/spf13/cobra"
3334

3435
nodev1 "github.com/CryptOS-PKI/cryptos-node/gen/go/cryptos/node/v1"
36+
"github.com/CryptOS-PKI/cryptos-node/internal/isoimage"
3537
"github.com/CryptOS-PKI/cryptos-node/internal/ukisign"
3638
)
3739

@@ -267,8 +269,10 @@ func newImageActivateCmd(opts *globalOpts) *cobra.Command {
267269
}
268270

269271
// newImageSignCmd signs an unsigned UKI with the operator's own key and makes
270-
// that key the image's upgrade anchor (cryptos-appliance#41). It runs on the
271-
// workstation and never talks to a node.
272+
// that key the image's upgrade anchor (cryptos-appliance#41). Given an ISO,
273+
// it signs the UKI the ISO carries and writes it back into the ISO
274+
// (cryptos-appliance#83). It runs on the workstation and never talks to a
275+
// node.
272276
func newImageSignCmd() *cobra.Command {
273277
var (
274278
imagePath string
@@ -278,12 +282,15 @@ func newImageSignCmd() *cobra.Command {
278282
)
279283
cmd := &cobra.Command{
280284
Use: "sign",
281-
Short: "Sign an unsigned image with your key and make it the upgrade anchor",
285+
Short: "Sign an unsigned image or ISO with your key and make it the upgrade anchor",
282286
Long: "Sign an unsigned CryptOS image (UKI) with your own Secure Boot key. The " +
283287
"certificate is added to the image as its upgrade anchor, the image is " +
284288
"Authenticode-signed for Secure Boot, and the detached release signature " +
285289
"'image stage' sends is written beside it as <out>.sig. A node installed " +
286290
"from the signed image takes in-place upgrades signed by the same key. " +
291+
"Given an unsigned ISO instead, it signs the UKI inside it the same way, " +
292+
"writes the signed UKI back over every copy in the ISO, and also writes " +
293+
"the signed UKI and its .sig beside the signed ISO for staging upgrades. " +
287294
"Runs locally; it does not contact a node.",
288295
Args: cobra.NoArgs,
289296
RunE: func(cmd *cobra.Command, _ []string) error {
@@ -294,11 +301,7 @@ func newImageSignCmd() *cobra.Command {
294301
return errors.New("--key is required")
295302
case certPath == "":
296303
return errors.New("--cert is required")
297-
}
298-
if outPath == "" {
299-
outPath = ukisign.DefaultOut(imagePath)
300-
}
301-
if outPath == imagePath {
304+
case outPath == imagePath:
302305
return errors.New("--out must differ from --image")
303306
}
304307

@@ -323,6 +326,16 @@ func newImageSignCmd() *cobra.Command {
323326
return err
324327
}
325328

329+
if isoimage.IsISO(image) {
330+
return signISO(cmd.OutOrStdout(), imagePath, outPath, image, key, cert)
331+
}
332+
333+
if outPath == "" {
334+
outPath = ukisign.DefaultOut(imagePath)
335+
}
336+
if outPath == imagePath {
337+
return errors.New("--out must differ from --image")
338+
}
326339
res, err := ukisign.Sign(image, key, cert)
327340
if err != nil {
328341
return err
@@ -338,14 +351,56 @@ func newImageSignCmd() *cobra.Command {
338351
return writeImageSigned(cmd.OutOrStdout(), outPath, sigPath, res.Image, cert)
339352
},
340353
}
341-
cmd.Flags().StringVar(&imagePath, "image", "", "unsigned CryptOS image (UKI) to sign")
354+
cmd.Flags().StringVar(&imagePath, "image", "", "unsigned CryptOS image to sign: a UKI, or an ISO that carries one")
342355
cmd.Flags().StringVar(&keyPath, "key", "", "RSA signing key, PEM (PKCS#1 or PKCS#8, not encrypted)")
343356
cmd.Flags().StringVar(&certPath, "cert", "", "signing certificate, PEM or DER; it becomes the upgrade anchor")
344-
cmd.Flags().StringVar(&outPath, "out", "", "signed image path (default: <image> without .unsigned, else <image>.signed.uki)")
357+
cmd.Flags().StringVar(&outPath, "out", "", "signed image path (default: <image> without .unsigned, else <image>.signed.uki; "+
358+
"for an ISO, <image> without -unsigned, else <image>.signed.iso, with the signed UKI beside it as <out without .iso>.uki)")
345359

346360
return cmd
347361
}
348362

363+
// signISO signs the UKI inside an ISO and writes the signed ISO, the signed
364+
// UKI and the UKI's detached signature. Nothing is written unless the signed
365+
// ISO passed its self-check.
366+
func signISO(w io.Writer, isoPath, outPath string, iso []byte, key crypto.Signer, cert *x509.Certificate) error {
367+
if outPath == "" {
368+
outPath = ukisign.DefaultISOOut(isoPath)
369+
}
370+
ukiPath := ukisign.ISOUKIPath(outPath)
371+
sigPath := ukiPath + ".sig"
372+
for _, p := range []string{outPath, ukiPath, sigPath} {
373+
if p == isoPath {
374+
return fmt.Errorf("signing %s would overwrite it; pick another --out", isoPath)
375+
}
376+
}
377+
res, err := ukisign.SignISO(iso, key, cert)
378+
if err != nil {
379+
return err
380+
}
381+
if err := os.WriteFile(outPath, res.ISO, 0o644); err != nil {
382+
return fmt.Errorf("write the signed ISO: %w", err)
383+
}
384+
if err := os.WriteFile(ukiPath, res.Image, 0o644); err != nil {
385+
return fmt.Errorf("write the signed image: %w", err)
386+
}
387+
if err := os.WriteFile(sigPath, res.Signature, 0o644); err != nil {
388+
return fmt.Errorf("write the release signature: %w", err)
389+
}
390+
391+
sum := sha256.Sum256(res.ISO)
392+
if _, err := fmt.Fprintf(w, "Signed ISO: %s\nISO SHA-256: %s\n", outPath, hex.EncodeToString(sum[:])); err != nil {
393+
return err
394+
}
395+
for _, c := range res.Copies {
396+
if _, err := fmt.Fprintf(w, "Signed in ISO: %s\n", c); err != nil {
397+
return err
398+
}
399+
}
400+
401+
return writeImageSigned(w, ukiPath, sigPath, res.Image, cert)
402+
}
403+
349404
// writeImageSigned reports what was written and which certificate the image
350405
// now trusts for upgrades, by subject and SHA-256 fingerprint.
351406
func writeImageSigned(w io.Writer, outPath, sigPath string, image []byte, cert *x509.Certificate) error {

‎cmd/cryptosctl/imagesign_test.go‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import (
3030
"testing"
3131
"time"
3232

33+
"github.com/CryptOS-PKI/cryptos-node/internal/isoimage/isotest"
3334
"github.com/CryptOS-PKI/cryptos-node/internal/ukisign"
3435
)
3536

@@ -114,3 +115,26 @@ func TestImageSign_RefusesANonUKIAndWritesNothing(t *testing.T) {
114115
}
115116
}
116117
}
118+
119+
// An ISO is told apart from a UKI by its content. One whose boot image holds
120+
// something that is not a UKI is refused, and none of the three outputs is
121+
// written.
122+
func TestImageSign_ISOWithANonUKIIsRefusedAndWritesNothing(t *testing.T) {
123+
dir := t.TempDir()
124+
keyPath, certPath := writeSigningPair(t, dir)
125+
iso := filepath.Join(dir, "cryptos-amd64-vmware-unsigned-abc1234.iso")
126+
built := isotest.Build(t, bytes.Repeat([]byte("not a PE "), 1000), isotest.Options{GPT: true})
127+
if err := os.WriteFile(iso, built.Image, 0o600); err != nil {
128+
t.Fatal(err)
129+
}
130+
131+
err := runSign(t, "--image", iso, "--key", keyPath, "--cert", certPath)
132+
if !errors.Is(err, ukisign.ErrNotUKI) {
133+
t.Fatalf("err = %v, want ErrNotUKI", err)
134+
}
135+
for _, name := range []string{"cryptos-amd64-vmware-abc1234.iso", "cryptos-amd64-vmware-abc1234.uki", "cryptos-amd64-vmware-abc1234.uki.sig"} {
136+
if _, err := os.Stat(filepath.Join(dir, name)); !errors.Is(err, os.ErrNotExist) {
137+
t.Errorf("%s was written for a refused ISO", name)
138+
}
139+
}
140+
}

‎docs/image-sign.md‎

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,15 @@ A node installed from an image you signed this way takes signed in-place
1919
upgrades: any later image you sign with the same key can be staged onto it
2020
with `cryptosctl image stage`.
2121

22+
It signs an unsigned ISO the same way, so you can install from a signed ISO
23+
without rebuilding it; see [Sign an ISO](#sign-an-iso).
24+
2225
## Before you start
2326

24-
- An unsigned CryptOS UKI, for example a release asset or
25-
`build/out/cryptos-amd64.uki.unsigned` from a cryptos-appliance build.
27+
- An unsigned CryptOS UKI or ISO, for example a release asset, or
28+
`build/out/cryptos-amd64.uki.unsigned` or
29+
`build/out/cryptos-amd64-vmware-unsigned-<build>.iso` from a
30+
cryptos-appliance build.
2631
- Your Secure Boot signing key as an unencrypted RSA PEM file (PKCS#1 or
2732
PKCS#8), and its certificate as PEM or DER. This is the certificate
2833
enrolled in your machines' Secure Boot db. Keep the key file readable by you
@@ -62,6 +67,53 @@ with `cryptosctl image stage`.
6267

6368
`image stage` picks up `cryptos-amd64.uki.sig` by default.
6469

70+
## Sign an ISO
71+
72+
Give `--image` an ISO instead of a UKI. The command tells the two apart by
73+
their content, not their names. It needs no root, no `xorriso` and no rebuild.
74+
75+
1. Sign it:
76+
77+
```sh
78+
cryptosctl image sign \
79+
--image cryptos-amd64-vmware-unsigned-v0.1.0.iso \
80+
--key release.key \
81+
--cert release.crt
82+
```
83+
84+
It signs the UKI inside the ISO exactly as it signs a UKI on its own, then
85+
writes the signed UKI back over every copy the ISO carries:
86+
87+
- `EFI/BOOT/BOOTX64.EFI` in the EFI boot image, which the firmware boots;
88+
- `/cryptos.uki`, which the installer copies to the disk it installs.
89+
90+
It writes three files. With no `--out`, the signed ISO is the input name
91+
without `-unsigned` (`cryptos-amd64-vmware-v0.1.0.iso`); an input with any
92+
other name gets `.signed.iso`. Beside the ISO it writes the signed UKI,
93+
named like the ISO with `.uki` in place of `.iso`
94+
(`cryptos-amd64-vmware-v0.1.0.uki`), and that UKI's release signature
95+
(`cryptos-amd64-vmware-v0.1.0.uki.sig`). The command prints each place in
96+
the ISO it signed, the paths and the SHA-256 of the ISO and the UKI.
97+
98+
Before it writes anything, it reads the new ISO back, checks that every
99+
copy is the signed UKI, and checks the signature and the anchor.
100+
101+
2. Optionally, check the Secure Boot signature on the UKI written beside the
102+
ISO, the same bytes as the copies inside it:
103+
104+
```sh
105+
sbverify --cert release.crt cryptos-amd64-vmware-v0.1.0.uki
106+
```
107+
108+
3. Install a node from the signed ISO. Keep the signed `.uki` and `.sig`:
109+
they are what you stage onto a node, and later ISOs or UKIs you sign with
110+
the same key upgrade every node installed from this one.
111+
112+
> [!CAUTION]
113+
> The EFI boot image inside the ISO keeps its size. A cryptos-appliance build
114+
> leaves 2 MiB free in it, far more than signing adds, but an ISO built some
115+
> other way with no free space is refused, and nothing is written.
116+
65117
## What it changes in the image
66118

67119
- **The anchor.** A small uncompressed cpio archive holding

0 commit comments

Comments
 (0)