diff --git a/pkg/blueprint/ca_customizations.go b/pkg/blueprint/ca_customizations.go new file mode 100644 index 0000000000..560bac66fa --- /dev/null +++ b/pkg/blueprint/ca_customizations.go @@ -0,0 +1,5 @@ +package blueprint + +type CACustomization struct { + PEMCerts []string `json:"pem_certs,omitempty" toml:"pem_certs,omitempty"` +} diff --git a/pkg/blueprint/customizations.go b/pkg/blueprint/customizations.go index 28ea761051..f0d800077f 100644 --- a/pkg/blueprint/customizations.go +++ b/pkg/blueprint/customizations.go @@ -6,6 +6,7 @@ import ( "slices" "strings" + "github.com/osbuild/images/pkg/cert" "github.com/osbuild/images/pkg/customizations/anaconda" ) @@ -31,6 +32,7 @@ type Customizations struct { Installer *InstallerCustomization `json:"installer,omitempty" toml:"installer,omitempty"` RPM *RPMCustomization `json:"rpm,omitempty" toml:"rpm,omitempty"` RHSM *RHSMCustomization `json:"rhsm,omitempty" toml:"rhsm,omitempty"` + CACerts *CACustomization `json:"cacerts,omitempty" toml:"ca,omitempty"` } type IgnitionCustomization struct { @@ -425,3 +427,32 @@ func (c *Customizations) GetRHSM() *RHSMCustomization { } return c.RHSM } + +func (c *Customizations) checkCACerts() error { + if c == nil { + return nil + } + + if c.CACerts != nil { + for _, bundle := range c.CACerts.PEMCerts { + _, err := cert.ParseCerts(bundle) + if err != nil { + return err + } + } + } + + return nil +} + +func (c *Customizations) GetCACerts() (*CACustomization, error) { + if c == nil { + return nil, nil + } + + if err := c.checkCACerts(); err != nil { + return nil, err + } + + return c.CACerts, nil +} diff --git a/pkg/cert/parsecerts.go b/pkg/cert/parsecerts.go new file mode 100644 index 0000000000..a14897fb04 --- /dev/null +++ b/pkg/cert/parsecerts.go @@ -0,0 +1,36 @@ +package cert + +import ( + "crypto/x509" + "encoding/pem" + "fmt" +) + +// ParseCerts parses a PEM-encoded certificate chain formatted as concatenated strings +// and returns a slice of x509.Certificate. +func ParseCerts(cert string) ([]*x509.Certificate, error) { + result := make([]*x509.Certificate, 0, 1) + block := []byte(cert) + var blocks [][]byte + for { + var certDERBlock *pem.Block + certDERBlock, block = pem.Decode(block) + if certDERBlock == nil { + break + } + + if certDERBlock.Type == "CERTIFICATE" { + blocks = append(blocks, certDERBlock.Bytes) + } + } + + for _, block := range blocks { + cert, err := x509.ParseCertificate(block) + if err != nil { + return nil, fmt.Errorf("failed to parse certificate: %w", err) + } + result = append(result, cert) + } + + return result, nil +} diff --git a/pkg/cert/parsecerts_test.go b/pkg/cert/parsecerts_test.go new file mode 100644 index 0000000000..bddc32cdd5 --- /dev/null +++ b/pkg/cert/parsecerts_test.go @@ -0,0 +1,46 @@ +package cert + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// taken from osbuild:test/data/certs/cert1.pem +const exampleCert = ` +-----BEGIN CERTIFICATE----- +MIIDhTCCAm2gAwIBAgIUVya7VJ3O8W8SqwuEa0BZ4HSsXvAwDQYJKoZIhvcNAQEL +BQAwUTELMAkGA1UEBhMCREUxDzANBgNVBAgMBkJlcmxpbjEPMA0GA1UEBwwGQmVy +bGluMQwwCgYDVQQKDANPcmcxEjAQBgNVBAMMCWxvY2FsaG9zdDAgFw0yNDA4MjYx +MDQyNDBaGA8yMTI0MDgwMjEwNDI0MFowUTELMAkGA1UEBhMCREUxDzANBgNVBAgM +BkJlcmxpbjEPMA0GA1UEBwwGQmVybGluMQwwCgYDVQQKDANPcmcxEjAQBgNVBAMM +CWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJnGjlvN +O3F/Z7Lr/r+6Xp2DosnNwoPHhG2e61KnFzgZfaxbklal5ORpuV/gLIg7lrbpdZe7 +WvK+16RanL6fLitis/tYVFyvz1MXqBYYrEoFGvVg9fOiis7hjpdZcpNDH9SngoAN +O0Wvv4T6LQS0cC7ZAFZjvmJ+RiZEbzRkNG5pUddZXbotE6htNfLgA5L1wIBgllrM +4DVkG0yNKmzqPNzfPTbdUgWCfjaQShHy1GP8KNEwFxM31F2wvQxsEb77o1S44Out +mlsi83tti6P7KjDk7w2j2zZO1X0xI8pflv3TBkJT1Am8vnk6rVnNO4pCpop3+kma +pDUEzBQmSQA5R1ECAwEAAaNTMFEwHQYDVR0OBBYEFDxFcFgPEsgsDixfKxB0uYGN +aJmzMB8GA1UdIwQYMBaAFDxFcFgPEsgsDixfKxB0uYGNaJmzMA8GA1UdEwEB/wQF +MAMBAf8wDQYJKoZIhvcNAQELBQADggEBAFih4lUbLlhKwIAV9x3/W7Mih8xUEdZr +olquZgaHedFet+ByAHvoES3pec7AVYTOD53mjgyZubD6INnVHzKyS4AG9ydD73o4 +cmm3DKxBaesvlHeTn0MOKsoM8QCxeyFJmiUPpgDBok/PFnbGR9+JcsrlGJAnsSKD +vWpiwYcBauZ9nnK5yDe5M9XNFPkNDZzbKvWU7Sw3ziMT/+bRJse5vTrYcyOnNGgy +gZNz2nimKy1U8XZVAVwOV0rdGEFrfMln8DkRW86rGK/EncaVsl0SSP/rmjQgiX8Q +3CZraQGujJP932HSwUfdCX9yh+rTjE3MEnbqMoLzJa4BXB2aDQWtywU= +-----END CERTIFICATE----- +` + +func TestParseCerts(t *testing.T) { + certs, err := ParseCerts(exampleCert) + assert.Nil(t, err) + assert.Equal(t, len(certs), 1) + assert.Equal(t, "localhost", certs[0].Subject.CommonName) +} + +func TestParseConcatenatedCerts(t *testing.T) { + certs, err := ParseCerts(exampleCert + "\n" + exampleCert) + assert.Nil(t, err) + assert.Equal(t, len(certs), 2) + assert.Equal(t, "localhost", certs[0].Subject.CommonName) +} diff --git a/pkg/distro/fedora/images.go b/pkg/distro/fedora/images.go index c40c42e943..4c04a06c4f 100644 --- a/pkg/distro/fedora/images.go +++ b/pkg/distro/fedora/images.go @@ -226,6 +226,14 @@ func osCustomizations( osc.Files = append(osc.Files, imageConfig.Files...) osc.Directories = append(osc.Directories, imageConfig.Directories...) + ca, err := c.GetCACerts() + if err != nil { + panic(fmt.Sprintf("unexpected error checking CA certs: %v", err)) + } + if ca != nil { + osc.CACerts = ca.PEMCerts + } + return osc, nil } diff --git a/pkg/distro/rhel/images.go b/pkg/distro/rhel/images.go index 7a20febcc3..806463b921 100644 --- a/pkg/distro/rhel/images.go +++ b/pkg/distro/rhel/images.go @@ -271,6 +271,14 @@ func osCustomizations( osc.NoBLS = *imageConfig.NoBLS } + ca, err := c.GetCACerts() + if err != nil { + panic(fmt.Sprintf("unexpected error checking CA certs: %v", err)) + } + if ca != nil { + osc.CACerts = ca.PEMCerts + } + return osc, nil } diff --git a/pkg/manifest/os.go b/pkg/manifest/os.go index 38554595f2..02c01df807 100644 --- a/pkg/manifest/os.go +++ b/pkg/manifest/os.go @@ -1,6 +1,7 @@ package manifest import ( + "encoding/pem" "fmt" "path/filepath" "strings" @@ -11,6 +12,7 @@ import ( "github.com/osbuild/images/internal/environment" "github.com/osbuild/images/internal/workload" "github.com/osbuild/images/pkg/arch" + "github.com/osbuild/images/pkg/cert" "github.com/osbuild/images/pkg/container" "github.com/osbuild/images/pkg/customizations/bootc" "github.com/osbuild/images/pkg/customizations/fsnode" @@ -141,6 +143,8 @@ type OSCustomizations struct { Directories []*fsnode.Directory Files []*fsnode.File + CACerts []string + FIPS bool // NoBLS configures the image bootloader with traditional menu entries @@ -822,6 +826,25 @@ func (p *OS) serialize() osbuild.Pipeline { } } + if len(p.CACerts) > 0 { + for _, cc := range p.CACerts { + certs, err := cert.ParseCerts(cc) + if err != nil { + panic(fmt.Errorf("failed to parse CA certificates: %v", err)) + } + + for _, c := range certs { + path := filepath.Join("/etc/pki/ca-trust/source/anchors", filepath.Base(c.SerialNumber.Text(16))+".pem") + f, err := fsnode.NewFile(path, nil, "root", "root", pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: c.Raw})) + if err != nil { + panic(err) + } + pipeline.AddStages(osbuild.GenFileNodesStages([]*fsnode.File{f})...) + } + } + pipeline.AddStage(osbuild.NewCAStageStage()) + } + return pipeline } diff --git a/pkg/osbuild/pki_update_ca_trust_stage.go b/pkg/osbuild/pki_update_ca_trust_stage.go new file mode 100644 index 0000000000..7948292791 --- /dev/null +++ b/pkg/osbuild/pki_update_ca_trust_stage.go @@ -0,0 +1,7 @@ +package osbuild + +func NewCAStageStage() *Stage { + return &Stage{ + Type: "org.osbuild.pki.update-ca-trust", + } +} diff --git a/test/configs/all-customizations.json b/test/configs/all-customizations.json index e7ab8f346d..beb3a92390 100644 --- a/test/configs/all-customizations.json +++ b/test/configs/all-customizations.json @@ -171,7 +171,12 @@ "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQGiBGRBSJURBACzCoe9UNfxOUiFLq9b60weSBFdr39mLViscecDWATNvXtgRoK/\nxl/4qpayzALRCQ2Ek/pMrbKPF/3ngECuBv7S+rI4n/rIia4FNcqzYeZAz4DE4NP/\neUGvz49tWhmH17hX/rmF9kz5kLq2bDZI4GDgZW/oMDdt2ivj092Ljm9jRwCgyQy3\nWEK6RJvIcSEh9vbdwVdMPOcD/iHqNejTMFwGyZfCWB0eIOoxUOUn/ZZpELTL2UpW\nGduCf3txb5SkK7M+WDbb0S5IvNXoi0tc13STiD6Oxg2O9PkSvvYb+8zxlhNoSTwy\n54j7Rf5FlnQ3TAFfjtQ5LCx56LKK73j4RjvKW//ktm5n54exsgo9Ry/e12T46dRg\n7tIlA/91rzLm57Qyc73A7zjgIzef9O6V5ZzowC+pp/jfb5pS9hXgROekLkMgX0vg\niA5rM5OpqK4bArVP1lRWnLyvghwO+TW763RVuXlS0scfzMy4g0NgrG6j7TIOKEqz\n4xQxOuwkudqiQr/kOqKuLxQBXa+5MJkyhfPmqYw5wpqyCwFa/7Q4b3NidWlsZCB0\nZXN0IChvc2J1aWxkIHRlc3QgZ3Bna2V5KSA8b3NidWlsZEBleGFtcGxlLmNvbT6I\newQTEQIAOxYhBGB8woiEPRKBO8Cr31lulpQgMejzBQJkQUiVAhsjBQsJCAcCAiIC\nBhUKCQgLAgQWAgMBAh4HAheAAAoJEFlulpQgMejzapMAoLmUg1mNDTRUaCrN/fzm\nHYLHL6jkAJ9pEKkJQiHB6SfD0fkiD2GkELYLubkBDQRkQUiVEAQAlAAXrQ572vuw\nxI3W8GSZmOQiAYOQmOKRloLEy6VZ3NSOb9y2TXj33QTkJBPOM17AzB7E+YjZrpUt\ngl6LlXmfjMcJAcXhFaUBCilAcMwMlLl7DtnSkLnLIXYmHiN0v83BH/H0EPutOc5l\n0QIyugutifp9SJz2+EWpC4bjA7GFkQ8AAwUD/1tLEGqCJ37O8gfzYt2PWkqBEoOY\n0Z3zwVS6PWW/IIkak9dAJ0iX5NMeFWpzFNfviDPHqhEdUR55zsxyUZIZlCX5jwmA\nt7qm3cbH4HNU1Ogq3Q9hykbTPWPZVkpvNm/TO8TA2brhkz3nuS8Hbmh+rjXFOSZj\nDQBUxItuuj2hhpQEiGAEGBECACAWIQRgfMKIhD0SgTvAq99ZbpaUIDHo8wUCZEFI\nlQIbDAAKCRBZbpaUIDHo83fQAKDHgFIaggaNsvDQkj7vMX0fecHRhACfS9Bvxn2W\nWSb6T+gChmYBseZwk/k=\n=DQ3i\n-----END PGP PUBLIC KEY BLOCK-----\n" ] } - ] + ], + "cacerts": { + "pem_certs": [ + "-----BEGIN CERTIFICATE-----\nMIIDszCCApugAwIBAgIUJ4lK+JfdJCNgcEVxZDinJfKKbQswDQYJKoZIhvcNAQEL\nBQAwaDELMAkGA1UEBhMCVVMxFzAVBgNVBAgMDk5vcnRoIENhcm9saW5hMRAwDgYD\nVQQHDAdSYWxlaWdoMRAwDgYDVQQKDAdSZWQgSGF0MRwwGgYDVQQDDBNUZXN0IENB\nIGZvciBvc2J1aWxkMCAXDTI0MDkwMzEzMjkyMFoYDzIyOTgwNjE4MTMyOTIwWjBo\nMQswCQYDVQQGEwJVUzEXMBUGA1UECAwOTm9ydGggQ2Fyb2xpbmExEDAOBgNVBAcM\nB1JhbGVpZ2gxEDAOBgNVBAoMB1JlZCBIYXQxHDAaBgNVBAMME1Rlc3QgQ0EgZm9y\nIG9zYnVpbGQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDeA7OcWTrV\ngstoBsUaeJKm8nelg7Lc0WNXH6yOTLsr4td4yHs0YOvFGwgSf+ffV3RAG1mgqnMG\nMgkD2+z+7QhHbHHs3y0d0zfhA2bg0KVvfCWk7fNRPHY0UOePpXk245Bfw3D0VTpl\nF7nePk1I7ZY09snPWUeb2rjKXzYjKjzM0h27+ykV8I8+FbdyPk/pR8whyDqtHLUa\nXfFy2TFloDSYMkHKVd38BnL0bj91x5F+KsZkN4HzfbYwxLbCQfOSgy7q6TWce9kq\nLo6tya9vuvpWFm1dye7L+BodAQAq/dI/JMeCfyTb0eFb+tyzfr5aVIoqqDN+p9ft\ncw4OefpHbhtNAgMBAAGjUzBRMB0GA1UdDgQWBBRV2A9YmusekPzu5Yf08cV0oPL1\nwjAfBgNVHSMEGDAWgBRV2A9YmusekPzu5Yf08cV0oPL1wjAPBgNVHRMBAf8EBTAD\nAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQCgQZ2Xfj+NxaKBZgn2KNxS0MTbhzHRz6Rn\nqJs+h8OUz2Crmaf6N+RHlmDRZXUrDjSHpxVT2LxFy7ofRrLYIezFDUYfb920VkkV\nSVcxh1YDFROJalfMoE6wdyR/LnK4MJZS9fUpeCJJc/A0J+9FK9CwcyUrHgJ8XbJh\nMKYyQ+cf6O7wzutuBpMyRqSKS+hVM7BQTmSFvv1eAJlo6klGAmmKiYmAEvcQadH1\ndjrujsA3Cn5vX2L+0yuiLB5/zoxqx5cEy97TuKUYB8OqMMujAXNzF4L3HJDUNba2\nAhEkFozMXwYX73TGbGZ0mawPS5D3v3tYTEmJFf6SnVCmUW1fs57g\n-----END CERTIFICATE-----\n" + ] + } } } }