From 47df361761ca345b5c8924ec5dfde15aacca068f Mon Sep 17 00:00:00 2001 From: Alexey Odinokov Date: Tue, 26 Jan 2021 04:52:25 +0000 Subject: [PATCH] Implement genCAEx and genSignedCertEx with Subj support The original sprig-library implementation of genCA and genSignedCert may accept only CN parameter which isn't enough for K8s admin certificate. That certification must have O=system:masters, e.g. /CN=admin/O=system:masters This PR introduces the set of functions that insted of cn accept subj argument that may have a form compatible with openssl -subj parameter. If the first symbol isn't '/' subj behaves as cn argument. The set of new functions that accept subj arg is: genCAEx genCAWithKeyEx genSignedCertEx genSignedCertWithKeyEx Since the implementaion required to copy some non-exported helper functions from sprig, the decision was made to separate all go-template extension functions into a separate package: extlib. This package can be reused in other go-applications, it's just necessary to use GenericFuncMap function to get function-map. Change-Id: I0ffddee2e597323803bf5f1b54f315ded424b7be --- .../plugin/templater/extlib/crypto.go | 365 ++++++++++++++++++ .../plugin/templater/extlib/crypto_test.go | 170 ++++++++ pkg/document/plugin/templater/extlib/fs.go | 24 ++ .../plugin/templater/extlib/fs_test.go | 26 ++ .../plugin/templater/extlib/funcmap.go | 39 ++ .../plugin/templater/extlib/regexgen.go | 31 ++ .../plugin/templater/extlib/regexgen_test.go | 57 +++ .../plugin/templater/extlib/sprig_crypto.go | 220 +++++++++++ .../templater/extlib/sprig_crypto_test.go | 168 ++++++++ pkg/document/plugin/templater/extlib/yaml.go | 32 ++ .../plugin/templater/extlib/yaml_test.go | 44 +++ pkg/document/plugin/templater/templater.go | 55 +-- .../plugin/templater/templater_test.go | 69 ++++ 13 files changed, 1265 insertions(+), 35 deletions(-) create mode 100644 pkg/document/plugin/templater/extlib/crypto.go create mode 100644 pkg/document/plugin/templater/extlib/crypto_test.go create mode 100644 pkg/document/plugin/templater/extlib/fs.go create mode 100644 pkg/document/plugin/templater/extlib/fs_test.go create mode 100644 pkg/document/plugin/templater/extlib/funcmap.go create mode 100644 pkg/document/plugin/templater/extlib/regexgen.go create mode 100644 pkg/document/plugin/templater/extlib/regexgen_test.go create mode 100644 pkg/document/plugin/templater/extlib/sprig_crypto.go create mode 100644 pkg/document/plugin/templater/extlib/sprig_crypto_test.go create mode 100644 pkg/document/plugin/templater/extlib/yaml.go create mode 100644 pkg/document/plugin/templater/extlib/yaml_test.go diff --git a/pkg/document/plugin/templater/extlib/crypto.go b/pkg/document/plugin/templater/extlib/crypto.go new file mode 100644 index 000000000..38f5c0290 --- /dev/null +++ b/pkg/document/plugin/templater/extlib/crypto.go @@ -0,0 +1,365 @@ +/* + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package extlib + +import ( + "bytes" + "errors" + "fmt" + "strings" + "unicode/utf8" + + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "time" +) + +func toUint32(i int) uint32 { return uint32(i) } + +type dnParser struct { + in string + i int + cur bytes.Buffer + state int + dn []string +} + +func (p *dnParser) startOver() { + p.dn = append(p.dn, p.cur.String()) + p.cur = bytes.Buffer{} + p.state = 0 +} + +func (p *dnParser) parseParam(r rune) error { + switch r { + case '\\': + p.state = 2 + case '+', '/': + return fmt.Errorf("string %s has separator '%c', but didn't have value on position %d", p.in, r, p.i+1) + case '=': + p.state = 1 + p.cur.WriteRune(r) + case '"', ',', '<', '>', ';': + return fmt.Errorf("string %s position %d: having %c without '\\'", p.in, p.i+1, r) + default: + p.cur.WriteRune(r) + } + return nil +} + +func (p *dnParser) parseValue(r rune) error { + switch r { + case '\\': + p.state = 3 + case '+', '/': + p.startOver() + case '=': + return fmt.Errorf("string %s has extra '=' on position %d", p.in, p.i+1) + case '"', ',', '<', '>', ';': + return fmt.Errorf("string %s position %d: having %c without '\\'", p.in, p.i+1, r) + default: + p.cur.WriteRune(r) + } + return nil +} + +func (p *dnParser) parseParamEscape(r rune) error { + switch r { + case '=', '+', '/', '"', ',', '<', '>', ';': + p.cur.WriteRune(r) + p.state = 0 + default: + return fmt.Errorf("string %s pos %d: %c shouldn't follow after '\\'", p.in, p.i+1, r) + } + return nil +} + +func (p *dnParser) parseValueEscape(r rune) error { + switch r { + case '=', '+', '/', '"', ',', '<', '>', ';': + p.cur.WriteRune(r) + p.state = 1 + default: + return fmt.Errorf("string %s pos %d: %c shouldn't follow after '\\'", p.in, p.i+1, r) + } + return nil +} + +func (p *dnParser) Parse(in string) error { + p.cur = bytes.Buffer{} + p.state = 0 + p.dn = nil + + p.in = in + var err error + for p.i = 0; p.i < len(p.in); { + r, size := utf8.DecodeRuneInString(p.in[p.i:]) + switch p.state { + case 0: // initial state + err = p.parseParam(r) + case 1: // the same, but after = + err = p.parseValue(r) + case 2: // state inside \ + err = p.parseParamEscape(r) + case 3: // state inside \ after = + err = p.parseValueEscape(r) + } + + if err != nil { + return err + } + + p.i += size + } + + if p.state != 1 { + return fmt.Errorf("string %s terminates incorrectly", p.in) + } + p.startOver() + return nil +} + +// Converts RFC 2253 Distinguished Names syntax back to +// Name similar to what openssl parse_name function [1] +// does, except that if it doesn't have / as a first simbol +// it assumes that the whole string is CN. +// we don't support MultiRdn - + is treated the same way as /. +// [1] https://github.com/openssl/openssl/blob/d8ab30be9cc4d4e77008d4037e696bc41ce293f8/apps/lib/apps.c#L1624 +func nameFromString(in string) (*pkix.Name, error) { + if len(in) > 0 && in[0] != '/' { + return &pkix.Name{ + CommonName: in, + }, nil + } + + in = in[1:] + if len(in) == 0 { + return &pkix.Name{ + CommonName: in, + }, nil + } + + p := &dnParser{} + err := p.Parse(in) + if err != nil { + return nil, err + } + + return nameFromDn(p.dn) +} + +func nameFromDn(dn []string) (*pkix.Name, error) { + name := pkix.Name{} + + for _, v := range dn { + sv := strings.Split(v, "=") + if len(sv) != 2 { + return nil, fmt.Errorf("%s must have a form key=value", v) + } + switch sv[0] { + case "CN": + if name.CommonName != "" { + return nil, fmt.Errorf("CN is already set") + } + name.CommonName = sv[1] + case "SERIALNUMBER": + if name.SerialNumber != "" { + return nil, fmt.Errorf("SERIALNUMBER is already set") + } + name.SerialNumber = sv[1] + case "C": + name.Country = append(name.Country, sv[1]) + case "O": + name.Organization = append(name.Organization, sv[1]) + case "OU": + name.OrganizationalUnit = append(name.OrganizationalUnit, sv[1]) + case "L": + name.Locality = append(name.Locality, sv[1]) + case "ST": + name.Province = append(name.Province, sv[1]) + case "STREET": + name.StreetAddress = append(name.StreetAddress, sv[1]) + case "POSTALCODE": + name.PostalCode = append(name.PostalCode, sv[1]) + default: + return nil, fmt.Errorf("unsupported property %s", sv[0]) + } + } + + return &name, nil +} + +func generateCertificateAuthorityEx( + subj string, + daysValid int, +) (certificate, error) { + priv, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return certificate{}, fmt.Errorf("error generating rsa key: %s", err) + } + + return generateCertificateAuthorityWithKeyInternalEx(subj, daysValid, priv) +} + +func generateCertificateAuthorityWithPEMKeyEx( + subj string, + daysValid int, + privPEM string, +) (certificate, error) { + priv, err := parsePrivateKeyPEM(privPEM) + if err != nil { + return certificate{}, fmt.Errorf("parsing private key: %s", err) + } + return generateCertificateAuthorityWithKeyInternalEx(subj, daysValid, priv) +} + +func generateCertificateAuthorityWithKeyInternalEx( + subj string, + daysValid int, + priv crypto.PrivateKey, +) (certificate, error) { + ca := certificate{} + + template, err := getBaseCertTemplateEx(subj, nil, nil, daysValid) + if err != nil { + return ca, err + } + // Override KeyUsage and IsCA + template.KeyUsage = x509.KeyUsageKeyEncipherment | + x509.KeyUsageDigitalSignature | + x509.KeyUsageCertSign + template.IsCA = true + + ca.Cert, ca.Key, err = getCertAndKey(template, priv, template, priv) + + return ca, err +} + +func generateSignedCertificateEx( + subj string, + ips []interface{}, + alternateDNS []interface{}, + daysValid int, + ca certificate, +) (certificate, error) { + priv, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return certificate{}, fmt.Errorf("error generating rsa key: %s", err) + } + return generateSignedCertificateWithKeyInternalEx(subj, ips, alternateDNS, daysValid, ca, priv) +} + +func generateSignedCertificateWithPEMKeyEx( + subj string, + ips []interface{}, + alternateDNS []interface{}, + daysValid int, + ca certificate, + privPEM string, +) (certificate, error) { + priv, err := parsePrivateKeyPEM(privPEM) + if err != nil { + return certificate{}, fmt.Errorf("parsing private key: %s", err) + } + return generateSignedCertificateWithKeyInternalEx(subj, ips, alternateDNS, daysValid, ca, priv) +} + +func generateSignedCertificateWithKeyInternalEx( + subj string, + ips []interface{}, + alternateDNS []interface{}, + daysValid int, + ca certificate, + priv crypto.PrivateKey, +) (certificate, error) { + cert := certificate{} + + decodedSignerCert, _ := pem.Decode([]byte(ca.Cert)) + if decodedSignerCert == nil { + return cert, errors.New("unable to decode certificate") + } + signerCert, err := x509.ParseCertificate(decodedSignerCert.Bytes) + if err != nil { + return cert, fmt.Errorf( + "error parsing certificate: decodedSignerCert.Bytes: %s", + err, + ) + } + signerKey, err := parsePrivateKeyPEM(ca.Key) + if err != nil { + return cert, fmt.Errorf( + "error parsing private key: %s", + err, + ) + } + + template, err := getBaseCertTemplateEx(subj, ips, alternateDNS, daysValid) + if err != nil { + return cert, err + } + + cert.Cert, cert.Key, err = getCertAndKey( + template, + priv, + signerCert, + signerKey, + ) + + return cert, err +} + +func getBaseCertTemplateEx( + subj string, + ips []interface{}, + alternateDNS []interface{}, + daysValid int, +) (*x509.Certificate, error) { + ipAddresses, err := getNetIPs(ips) + if err != nil { + return nil, err + } + dnsNames, err := getAlternateDNSStrs(alternateDNS) + if err != nil { + return nil, err + } + serialNumberUpperBound := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, serialNumberUpperBound) + if err != nil { + return nil, err + } + name, err := nameFromString(subj) + if err != nil { + return nil, err + } + return &x509.Certificate{ + SerialNumber: serialNumber, + Subject: *name, + IPAddresses: ipAddresses, + DNSNames: dnsNames, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour * 24 * time.Duration(daysValid)), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{ + x509.ExtKeyUsageServerAuth, + x509.ExtKeyUsageClientAuth, + }, + BasicConstraintsValid: true, + }, nil +} diff --git a/pkg/document/plugin/templater/extlib/crypto_test.go b/pkg/document/plugin/templater/extlib/crypto_test.go new file mode 100644 index 000000000..63072cab8 --- /dev/null +++ b/pkg/document/plugin/templater/extlib/crypto_test.go @@ -0,0 +1,170 @@ +/* + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package extlib + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "crypto/x509/pkix" +) + +func TestToUint32(t *testing.T) { + assert.Equal(t, uint32(1), toUint32(1)) + assert.Equal(t, uint32(0xffffffff), toUint32(-1)) +} + +func TestNameFromString(t *testing.T) { + testCases := []struct { + in string + expectedOut pkix.Name + expectedErr string + }{ + { + in: `Kubernetes API`, + expectedOut: pkix.Name{ + CommonName: `Kubernetes API`, + }, + }, + { + in: `/CN=Kubernetes API`, + expectedOut: pkix.Name{ + CommonName: `Kubernetes API`, + }, + }, + { + in: `/CN=James \"Jim\" Smith\, III+O=example`, + expectedOut: pkix.Name{ + CommonName: `James "Jim" Smith, III`, + Organization: []string{ + `example`, + }, + }, + }, + { + in: `/CN=admin/O=system:masters`, + expectedOut: pkix.Name{ + CommonName: `admin`, + Organization: []string{ + `system:masters`, + }, + }, + }, + { + in: `/C=AU/ST=Some-State/O=Internet Widgits Pty Ltd/CN=leaf`, + expectedOut: pkix.Name{ + CommonName: `leaf`, + Country: []string{ + `AU`, + }, + Province: []string{ + `Some-State`, + }, + Organization: []string{ + `Internet Widgits Pty Ltd`, + }, + }, + }, + { + in: `/C=AU/ST=QLD/CN=SSLeay\/rsa test cert`, + expectedOut: pkix.Name{ + CommonName: `SSLeay/rsa test cert`, + Country: []string{ + `AU`, + }, + Province: []string{ + `QLD`, + }, + }, + }, + { + in: `/CN=CN/SERIALNUMBER=SN` + + `/C=C1/C=C2` + + `/O=O1/O=O2` + + `/OU=OU1/OU=OU2` + + `/L=L1/L=L2` + + `/ST=ST1/ST=ST2` + + `/STREET=S1/STREET=S2` + + `/POSTALCODE=PC1/POSTALCODE=PC2`, + expectedOut: pkix.Name{ + CommonName: `CN`, + SerialNumber: `SN`, + Country: []string{`C1`, `C2`}, + Organization: []string{`O1`, `O2`}, + OrganizationalUnit: []string{`OU1`, `OU2`}, + Locality: []string{`L1`, `L2`}, + Province: []string{`ST1`, `ST2`}, + StreetAddress: []string{`S1`, `S2`}, + PostalCode: []string{`PC1`, `PC2`}, + }, + }, + { + in: `/C=AU/ST=QLD/CN=SSLeay\/rsa test cert\`, + expectedErr: `string C=AU/ST=QLD/CN=SSLeay\/rsa test cert\ terminates incorrectly`, + }, + { + in: `/C=A\U/ST=QLD/CN=SSLeay\/rsa test cert`, + expectedErr: `string C=A\U/ST=QLD/CN=SSLeay\/rsa test cert pos 5: U shouldn't follow after '\'`, + }, + { + in: `/C\N=AU/ST=QLD/CN=SSLeay\/rsa test cert`, + expectedErr: `string C\N=AU/ST=QLD/CN=SSLeay\/rsa test cert pos 3: N shouldn't follow after '\'`, + }, + { + in: `/CN=AU/ST=QLD/CN=SSLeay\/rsa <>",test cert`, + expectedErr: `string CN=AU/ST=QLD/CN=SSLeay\/rsa <>",test cert position 29: having < without '\'`, + }, + { + in: `/CN=AU=AU/ST=QLD/CN=SSLeay\/rsa test cert`, + expectedErr: `string CN=AU=AU/ST=QLD/CN=SSLeay\/rsa test cert has extra '=' on position 6`, + }, + { + in: `/CN=AU/ST=QLD/CN<>",t=SSLeay\/rsa test cert`, + expectedErr: `string CN=AU/ST=QLD/CN<>",t=SSLeay\/rsa test cert position 16: having < without '\'`, + }, + { + in: `/CN=AU/ST/CN=SSLeay\/rsa <>",test cert`, + expectedErr: `string CN=AU/ST/CN=SSLeay\/rsa <>",test cert has separator '/', but didn't have value on position 9`, + }, + { + in: `/CN=AU/CN\<=SSLeay test cert`, + expectedErr: `unsupported property CN<`, + }, + { + in: `/CN=/SP=xxx/CN=SSLeay\/rsa test cert`, + expectedErr: `unsupported property SP`, + }, + { + in: `/CN=1/CN=SSLeay\/rsa test cert`, + expectedErr: `CN is already set`, + }, + { + in: `/CN=1/SERIALNUMBER=1/SERIALNUMBER=2`, + expectedErr: `SERIALNUMBER is already set`, + }, + } + + for _, tc := range testCases { + r, err := nameFromString(tc.in) + if tc.expectedErr != "" { + assert.EqualError(t, err, tc.expectedErr) + continue + } + require.NoError(t, err) + assert.Equal(t, tc.expectedOut, *r) + } +} diff --git a/pkg/document/plugin/templater/extlib/fs.go b/pkg/document/plugin/templater/extlib/fs.go new file mode 100644 index 000000000..e727c18e0 --- /dev/null +++ b/pkg/document/plugin/templater/extlib/fs.go @@ -0,0 +1,24 @@ +/* + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package extlib + +import ( + "opendev.org/airship/airshipctl/pkg/fs" +) + +func fileExists(path string) bool { + docfs := fs.NewDocumentFs() + return docfs.Exists(path) +} diff --git a/pkg/document/plugin/templater/extlib/fs_test.go b/pkg/document/plugin/templater/extlib/fs_test.go new file mode 100644 index 000000000..55c76b72a --- /dev/null +++ b/pkg/document/plugin/templater/extlib/fs_test.go @@ -0,0 +1,26 @@ +/* + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package extlib + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFileExists(t *testing.T) { + assert.Equal(t, true, fileExists("fs_test.go")) + assert.Equal(t, false, fileExists("fs_test_nonexistent.go")) +} diff --git a/pkg/document/plugin/templater/extlib/funcmap.go b/pkg/document/plugin/templater/extlib/funcmap.go new file mode 100644 index 000000000..2de52ebea --- /dev/null +++ b/pkg/document/plugin/templater/extlib/funcmap.go @@ -0,0 +1,39 @@ +/* + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package extlib + +import ( + "text/template" +) + +// GenericFuncMap returns a copy of the function map +func GenericFuncMap() template.FuncMap { + gfm := make(template.FuncMap, len(genericMap)) + for k, v := range genericMap { + gfm[k] = v + } + return gfm +} + +var genericMap = map[string]interface{}{ + "genCAEx": generateCertificateAuthorityEx, + "genCAWithKeyEx": generateCertificateAuthorityWithPEMKeyEx, + "genSignedCertEx": generateSignedCertificateEx, + "genSignedCertWithKeyEx": generateSignedCertificateWithPEMKeyEx, + "fileExists": fileExists, + "regexGen": regexGen, + "toYaml": toYaml, + "toUint32": toUint32, +} diff --git a/pkg/document/plugin/templater/extlib/regexgen.go b/pkg/document/plugin/templater/extlib/regexgen.go new file mode 100644 index 000000000..db5f000c9 --- /dev/null +++ b/pkg/document/plugin/templater/extlib/regexgen.go @@ -0,0 +1,31 @@ +/* + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package extlib + +import ( + "github.com/lucasjones/reggen" +) + +// Generate Regex +func regexGen(regex string, limit int) string { + if limit <= 0 { + panic("Limit cannot be less than or equal to 0") + } + str, err := reggen.Generate(regex, limit) + if err != nil { + panic(err) + } + return str +} diff --git a/pkg/document/plugin/templater/extlib/regexgen_test.go b/pkg/document/plugin/templater/extlib/regexgen_test.go new file mode 100644 index 000000000..1f6845530 --- /dev/null +++ b/pkg/document/plugin/templater/extlib/regexgen_test.go @@ -0,0 +1,57 @@ +/* + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package extlib + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRegexGen(t *testing.T) { + tpl := `{{- $regex := "^[a-z]{5,10}$" }} +{{- $nomatchregex := "^[a-z]{0,4}$" }} +true={{- regexMatch $regex (regexGen $regex 10) }}, +false={{- regexMatch $nomatchregex (regexGen $regex 10) }} +` + out, err := runRaw(tpl, nil) + if err != nil { + t.Error(err) + } + assert.Equal(t, ` +true=true, +false=false +`, out) +} + +func TestRegexPanicOnPattern(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Errorf("The code did not panic") + } + }() + + regexGen("[a-z", 1) +} + +func TestRegexPanicOnLimit(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Errorf("The code did not panic") + } + }() + + regexGen("[a-z]{0,4}", 0) +} diff --git a/pkg/document/plugin/templater/extlib/sprig_crypto.go b/pkg/document/plugin/templater/extlib/sprig_crypto.go new file mode 100644 index 000000000..a5adee463 --- /dev/null +++ b/pkg/document/plugin/templater/extlib/sprig_crypto.go @@ -0,0 +1,220 @@ +/* + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package extlib + +import ( + "bytes" + "errors" + "fmt" + "strings" + + "crypto" + "crypto/dsa" + "crypto/ecdsa" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/asn1" + "encoding/pem" + "math/big" + "net" +) + +// that pieces of code were copied from +// https://github.com/Masterminds/sprig/blob/868e7517d046cb7540e10345b09c0d70da584c8e/crypto.go#L405 +type certificate struct { + Cert string + Key string +} + +// DSAKeyFormat stores the format for DSA keys. +// Used by pemBlockForKey +type DSAKeyFormat struct { + Version int + P, Q, G, Y, X *big.Int +} + +func getCertAndKey( + template *x509.Certificate, + signeeKey crypto.PrivateKey, + parent *x509.Certificate, + signingKey crypto.PrivateKey, +) (string, string, error) { + signeePubKey, err := getPublicKey(signeeKey) + if err != nil { + return "", "", fmt.Errorf("error retrieving public key from signee key: %s", err) + } + derBytes, err := x509.CreateCertificate( + rand.Reader, + template, + parent, + signeePubKey, + signingKey, + ) + if err != nil { + return "", "", fmt.Errorf("error creating certificate: %s", err) + } + + certBuffer := bytes.Buffer{} + if err := pem.Encode( + &certBuffer, + &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}, + ); err != nil { + return "", "", fmt.Errorf("error pem-encoding certificate: %s", err) + } + + keyBuffer := bytes.Buffer{} + if err := pem.Encode( + &keyBuffer, + pemBlockForKey(signeeKey), + ); err != nil { + return "", "", fmt.Errorf("error pem-encoding key: %s", err) + } + + return certBuffer.String(), keyBuffer.String(), nil +} + +func pemBlockForKey(priv interface{}) *pem.Block { + switch k := priv.(type) { + case *rsa.PrivateKey: + return &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k)} + case *dsa.PrivateKey: + val := DSAKeyFormat{ + P: k.P, Q: k.Q, G: k.G, + Y: k.Y, X: k.X, + } + bytes, err := asn1.Marshal(val) + if err != nil { + return nil + } + return &pem.Block{Type: "DSA PRIVATE KEY", Bytes: bytes} + case *ecdsa.PrivateKey: + b, err := x509.MarshalECPrivateKey(k) + if err != nil { + return nil + } + return &pem.Block{Type: "EC PRIVATE KEY", Bytes: b} + default: + // attempt PKCS#8 format for all other keys + b, err := x509.MarshalPKCS8PrivateKey(k) + if err != nil { + return nil + } + return &pem.Block{Type: "PRIVATE KEY", Bytes: b} + } +} + +func parsePrivateKeyPEM(pemBlock string) (crypto.PrivateKey, error) { + block, _ := pem.Decode([]byte(pemBlock)) + if block == nil { + return nil, errors.New("no PEM data in input") + } + + if block.Type == "PRIVATE KEY" { + priv, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("decoding PEM as PKCS#8: %s", err) + } + return priv, nil + } else if !strings.HasSuffix(block.Type, " PRIVATE KEY") { + return nil, fmt.Errorf("no private key data in PEM block of type %s", block.Type) + } + + switch block.Type[:len(block.Type)-12] { // strip " PRIVATE KEY" + case "RSA": + priv, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parsing RSA private key from PEM: %s", err) + } + return priv, nil + case "EC": + priv, err := x509.ParseECPrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parsing EC private key from PEM: %s", err) + } + return priv, nil + case "DSA": + var k DSAKeyFormat + _, err := asn1.Unmarshal(block.Bytes, &k) + if err != nil { + return nil, fmt.Errorf("parsing DSA private key from PEM: %s", err) + } + priv := &dsa.PrivateKey{ + PublicKey: dsa.PublicKey{ + Parameters: dsa.Parameters{ + P: k.P, Q: k.Q, G: k.G, + }, + Y: k.Y, + }, + X: k.X, + } + return priv, nil + default: + return nil, fmt.Errorf("invalid private key type %s", block.Type) + } +} + +func getPublicKey(priv crypto.PrivateKey) (crypto.PublicKey, error) { + switch k := priv.(type) { + case interface{ Public() crypto.PublicKey }: + return k.Public(), nil + case *dsa.PrivateKey: + return &k.PublicKey, nil + default: + return nil, fmt.Errorf("unable to get public key for type %T", priv) + } +} + +func getNetIPs(ips []interface{}) ([]net.IP, error) { + if ips == nil { + return []net.IP{}, nil + } + var ipStr string + var ok bool + var netIP net.IP + netIPs := make([]net.IP, len(ips)) + for i, ip := range ips { + ipStr, ok = ip.(string) + if !ok { + return nil, fmt.Errorf("error parsing ip: %v is not a string", ip) + } + netIP = net.ParseIP(ipStr) + if netIP == nil { + return nil, fmt.Errorf("error parsing ip: %s", ipStr) + } + netIPs[i] = netIP + } + return netIPs, nil +} + +func getAlternateDNSStrs(alternateDNS []interface{}) ([]string, error) { + if alternateDNS == nil { + return []string{}, nil + } + var dnsStr string + var ok bool + alternateDNSStrs := make([]string, len(alternateDNS)) + for i, dns := range alternateDNS { + dnsStr, ok = dns.(string) + if !ok { + return nil, fmt.Errorf( + "error processing alternate dns name: %v is not a string", + dns, + ) + } + alternateDNSStrs[i] = dnsStr + } + return alternateDNSStrs, nil +} diff --git a/pkg/document/plugin/templater/extlib/sprig_crypto_test.go b/pkg/document/plugin/templater/extlib/sprig_crypto_test.go new file mode 100644 index 000000000..60e4b0cbb --- /dev/null +++ b/pkg/document/plugin/templater/extlib/sprig_crypto_test.go @@ -0,0 +1,168 @@ +/* + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package extlib + +import ( + "bytes" + "crypto/x509" + "encoding/pem" + "fmt" + "testing" + "text/template" + + "github.com/stretchr/testify/assert" + + sprig "github.com/Masterminds/sprig/v3" +) + +const ( + beginCertificate = "-----BEGIN CERTIFICATE-----" + endCertificate = "-----END CERTIFICATE-----" +) + +var ( + // fastCertKeyAlgos is the list of private key algorithms that are supported for certificate use, and + // are fast to generate. + fastCertKeyAlgos = []string{ + "ecdsa", + "ed25519", + } +) + +// copy needed tests from https://github.com/Masterminds/sprig/blob/master/crypto_test.go +func testGenCAEx(t *testing.T, keyAlgo *string, subj, expCN string) { + var genCAExpr string + if keyAlgo == nil { + genCAExpr = "genCAEx" + } else { + genCAExpr = fmt.Sprintf(`genPrivateKey "%s" | genCAWithKeyEx`, *keyAlgo) + } + + tpl := fmt.Sprintf( + `{{- $ca := %s "%s" 365 }} +{{ $ca.Cert }} +`, + genCAExpr, + subj, + ) + out, err := runRaw(tpl, nil) + if err != nil { + t.Error(err) + } + assert.Contains(t, out, beginCertificate) + assert.Contains(t, out, endCertificate) + + decodedCert, _ := pem.Decode([]byte(out)) + assert.Nil(t, err) + cert, err := x509.ParseCertificate(decodedCert.Bytes) + assert.Nil(t, err) + + assert.Equal(t, expCN, cert.Subject.CommonName) + assert.True(t, cert.IsCA) +} + +func TestGenCAEx(t *testing.T) { + testGenCAEx(t, nil, "foo ca", "foo ca") + testGenCAEx(t, nil, "/CN=bar ca", "bar ca") + for i, keyAlgo := range fastCertKeyAlgos { + t.Run(keyAlgo, func(t *testing.T) { + testGenCAEx(t, &fastCertKeyAlgos[i], "foo ca", "foo ca") + testGenCAEx(t, &fastCertKeyAlgos[i], "/CN=bar ca", "bar ca") + }) + } +} + +func testGenSignedCertEx(t *testing.T, caKeyAlgo, certKeyAlgo *string, subj, expCn string) { + const ( + ip1 = "10.0.0.1" + ip2 = "10.0.0.2" + dns1 = "bar.com" + dns2 = "bat.com" + ) + + var genCAExpr, genSignedCertExpr string + if caKeyAlgo == nil { + genCAExpr = "genCAEx" + } else { + genCAExpr = fmt.Sprintf(`genPrivateKey "%s" | genCAWithKeyEx`, *caKeyAlgo) + } + if certKeyAlgo == nil { + genSignedCertExpr = "genSignedCertEx" + } else { + genSignedCertExpr = fmt.Sprintf(`genPrivateKey "%s" | genSignedCertWithKeyEx`, *certKeyAlgo) + } + + tpl := fmt.Sprintf( + `{{- $ca := %s "foo" 3650 }} +{{- $cert := %s "%s" (list "%s" "%s") (list "%s" "%s") 365 $ca }} +{{ $cert.Cert }}`, + genCAExpr, + genSignedCertExpr, + subj, + ip1, + ip2, + dns1, + dns2, + ) + + out, err := runRaw(tpl, nil) + if err != nil { + t.Error(err) + } + assert.Contains(t, out, beginCertificate) + assert.Contains(t, out, endCertificate) + + decodedCert, _ := pem.Decode([]byte(out)) + assert.Nil(t, err) + cert, err := x509.ParseCertificate(decodedCert.Bytes) + assert.Nil(t, err) + + assert.Equal(t, expCn, cert.Subject.CommonName) + assert.Equal(t, 1, cert.SerialNumber.Sign()) + assert.Equal(t, 2, len(cert.IPAddresses)) + assert.Equal(t, ip1, cert.IPAddresses[0].String()) + assert.Equal(t, ip2, cert.IPAddresses[1].String()) + assert.Contains(t, cert.DNSNames, dns1) + assert.Contains(t, cert.DNSNames, dns2) + assert.False(t, cert.IsCA) +} + +func TestGenSignedCertEx(t *testing.T) { + testGenSignedCertEx(t, nil, nil, "foo ca", "foo ca") + testGenSignedCertEx(t, nil, nil, "/CN=bar ca", "bar ca") + for i, caKeyAlgo := range fastCertKeyAlgos { + for j, certKeyAlgo := range fastCertKeyAlgos { + t.Run(fmt.Sprintf("%s-%s", caKeyAlgo, certKeyAlgo), func(t *testing.T) { + testGenSignedCertEx(t, &fastCertKeyAlgos[i], &fastCertKeyAlgos[j], "foo ca", "foo ca") + testGenSignedCertEx(t, &fastCertKeyAlgos[i], &fastCertKeyAlgos[j], "/CN=bar ca", "bar ca") + }) + } + } +} + +// runRaw runs a template with the given variables and returns the result. +func runRaw(tpl string, vars interface{}) (string, error) { + funcMap := sprig.TxtFuncMap() + for i, v := range GenericFuncMap() { + funcMap[i] = v + } + t := template.Must(template.New("test").Funcs(funcMap).Parse(tpl)) + var b bytes.Buffer + err := t.Execute(&b, vars) + if err != nil { + return "", err + } + return b.String(), nil +} diff --git a/pkg/document/plugin/templater/extlib/yaml.go b/pkg/document/plugin/templater/extlib/yaml.go new file mode 100644 index 000000000..3daca133d --- /dev/null +++ b/pkg/document/plugin/templater/extlib/yaml.go @@ -0,0 +1,32 @@ +/* + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package extlib + +import ( + "sigs.k8s.io/kustomize/kyaml/yaml" +) + +// Render input yaml as output yaml +// This function is from the Helm project: +// https://github.com/helm/helm +// Copyright The Helm Authors +func toYaml(v interface{}) string { + data, err := yaml.Marshal(v) + if err != nil { + // Swallow errors inside of a template. + return "" + } + return string(data) +} diff --git a/pkg/document/plugin/templater/extlib/yaml_test.go b/pkg/document/plugin/templater/extlib/yaml_test.go new file mode 100644 index 000000000..6292f9efc --- /dev/null +++ b/pkg/document/plugin/templater/extlib/yaml_test.go @@ -0,0 +1,44 @@ +/* + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package extlib + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestToYaml(t *testing.T) { + data := []struct { + A string + B int + }{ + { + A: "test1", + B: 1, + }, + { + A: "test2", + B: 2, + }, + } + + assert.Equal(t, ` +- a: test1 + b: 1 +- a: test2 + b: 2 +`[1:], toYaml(&data)) +} diff --git a/pkg/document/plugin/templater/templater.go b/pkg/document/plugin/templater/templater.go index 33ff131de..f8aa62669 100644 --- a/pkg/document/plugin/templater/templater.go +++ b/pkg/document/plugin/templater/templater.go @@ -19,15 +19,15 @@ import ( "fmt" "text/template" - sprig "github.com/Masterminds/sprig/v3" - "github.com/lucasjones/reggen" - "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/kustomize/kyaml/kio" "sigs.k8s.io/kustomize/kyaml/yaml" airshipv1 "opendev.org/airship/airshipctl/pkg/api/v1alpha1" - "opendev.org/airship/airshipctl/pkg/fs" + + sprig "github.com/Masterminds/sprig/v3" + + extlib "opendev.org/airship/airshipctl/pkg/document/plugin/templater/extlib" ) var _ kio.Filter = &plugin{} @@ -48,14 +48,24 @@ func New(obj map[string]interface{}) (kio.Filter, error) { }, nil } +func funcMapAppend(fma, fmb template.FuncMap) template.FuncMap { + for k, v := range fmb { + _, ok := fma[k] + if ok { + panic(fmt.Errorf("Trying to redefine function %s that already exists", k)) + } + fma[k] = v + } + return fma +} + func (t *plugin) Filter(items []*yaml.RNode) ([]*yaml.RNode, error) { - docfs := fs.NewDocumentFs() out := &bytes.Buffer{} - funcMap := sprig.TxtFuncMap() - funcMap["toUint32"] = func(i int) uint32 { return uint32(i) } - funcMap["toYaml"] = toYaml - funcMap["regexGen"] = regexGen - funcMap["fileExists"] = docfs.Exists + + funcMap := template.FuncMap{} + funcMap = funcMapAppend(funcMap, sprig.TxtFuncMap()) + funcMap = funcMapAppend(funcMap, extlib.GenericFuncMap()) + tmpl, err := template.New("tmpl").Funcs(funcMap).Parse(t.Template) if err != nil { return nil, err @@ -79,28 +89,3 @@ func (t *plugin) Filter(items []*yaml.RNode) ([]*yaml.RNode, error) { } return append(items, res.Nodes...), nil } - -// Generate Regex -func regexGen(regex string, limit int) string { - if limit <= 0 { - panic("Limit cannot be less than or equal to 0") - } - str, err := reggen.Generate(regex, limit) - if err != nil { - panic(err) - } - return str -} - -// Render input yaml as output yaml -// This function is from the Helm project: -// https://github.com/helm/helm -// Copyright The Helm Authors -func toYaml(v interface{}) string { - data, err := yaml.Marshal(v) - if err != nil { - // Swallow errors inside of a template. - return "" - } - return string(data) -} diff --git a/pkg/document/plugin/templater/templater_test.go b/pkg/document/plugin/templater/templater_test.go index 07f7e2c74..f8b478f4c 100644 --- a/pkg/document/plugin/templater/templater_test.go +++ b/pkg/document/plugin/templater/templater_test.go @@ -24,6 +24,11 @@ import ( "sigs.k8s.io/kustomize/kyaml/kio" "sigs.k8s.io/yaml" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/pem" + "opendev.org/airship/airshipctl/pkg/document/plugin/templater" ) @@ -213,3 +218,67 @@ NoFileExists: false assert.Equal(t, tc.expectedOut, buf.String()) } } + +func TestGenSignedCertEx(t *testing.T) { + testCases := []struct { + cfg string + expectedSubject pkix.Name + }{ + { + cfg: ` +apiVersion: airshipit.org/v1alpha1 +kind: Templater +metadata: + name: notImportantHere +values: + name: test + regex: "^[a-z]{5,10}$" + limit: 0 +template: | + {{- $targetClusterCa:=genCAEx "Kubernetes API" 3650 }} + {{- $targetKubeconfigCert:= genSignedCertEx "/CN=admin/O=system:masters" nil nil 365 $targetClusterCa }} + cert: {{ $targetKubeconfigCert.Cert|b64enc|quote }} +`, + expectedSubject: pkix.Name{ + CommonName: `admin`, + Organization: []string{ + `system:masters`, + }, + }, + }, + } + + for _, tc := range testCases { + cfg := make(map[string]interface{}) + err := yaml.Unmarshal([]byte(tc.cfg), &cfg) + require.NoError(t, err) + plugin, err := templater.New(cfg) + require.NoError(t, err) + buf := &bytes.Buffer{} + nodes, err := plugin.Filter(nil) + require.NoError(t, err) + err = kio.ByteWriter{Writer: buf}.Write(nodes) + require.NoError(t, err) + + res := make(map[string]string) + err = yaml.Unmarshal(buf.Bytes(), &res) + require.NoError(t, err) + + key, err := base64.StdEncoding.DecodeString(res["cert"]) + require.NoError(t, err) + + der, _ := pem.Decode(key) + if der == nil { + t.Errorf("failed to find PEM block") + return + } + + cert, err := x509.ParseCertificate(der.Bytes) + if err != nil { + t.Errorf("failed to parse: %s", err) + return + } + cert.Subject.Names = nil + assert.Equal(t, tc.expectedSubject, cert.Subject) + } +}