|
| 1 | +// Package basetls encapsulates a set of base TLS settings (not keys/certificates) |
| 2 | +// configured via containers-tls-details.yaml(5). |
| 3 | +// |
| 4 | +// CLI integration should generally be done using c/image/pkg/cli/basetls/tlsdetails instead |
| 5 | +// of using the TLSDetailsFile directly. |
| 6 | +package basetls |
| 7 | + |
| 8 | +import ( |
| 9 | + "bytes" |
| 10 | + "crypto/tls" |
| 11 | + "encoding/json" |
| 12 | + "errors" |
| 13 | + "fmt" |
| 14 | + "slices" |
| 15 | +) |
| 16 | + |
| 17 | +// Config encapsulates user’s choices about base TLS settings, typically |
| 18 | +// configured via containers-tls-details.yaml(5). |
| 19 | +// |
| 20 | +// Most codebases should pass around the resulting *tls.Config, without depending on this subpackage; |
| 21 | +// this primarily exists as a separate type to allow passing the configuration around within (version-matched) RPC systems, |
| 22 | +// using the MarshalText/UnmarshalText methods. |
| 23 | +type Config struct { |
| 24 | + // We keep the text representation because we start with it, and this way we don't have |
| 25 | + // to implement formatting back to text. This is an internal detail, so we can change that later. |
| 26 | + text TLSDetailsFile |
| 27 | + config *tls.Config // Parsed from .text, both match |
| 28 | +} |
| 29 | + |
| 30 | +// TLSDetailsFile contains a set of TLS options. |
| 31 | +// |
| 32 | +// To consume such a file, most callers should use c/image/pkg/cli/basetls/tlsdetails instead |
| 33 | +// of dealing with this type explicitly. |
| 34 | +// |
| 35 | +// This type is exported primarily to allow creating parameter files programmatically |
| 36 | +// (and eventually the tlsdetails subpackage should provide an API to convert this type into |
| 37 | +// the appropriate file contents, so that callers don't need to do that manually). |
| 38 | +type TLSDetailsFile struct { |
| 39 | + // Keep this in sync with docs/containers-tls-details.yaml.5.md ! |
| 40 | + |
| 41 | + MinVersion string `yaml:"minVersion,omitempty"` // If set, minimum version to use throughout the program. |
| 42 | + CipherSuites []string `yaml:"cipherSuites,omitempty"` // If set, allowed TLS cipher suites to use throughout the program. |
| 43 | + NamedGroups []string `yaml:"namedGroups,omitempty"` // If set, allowed TLS named groups to use throughout the program. |
| 44 | +} |
| 45 | + |
| 46 | +// NewFromTLSDetails creates a Config from a TLSDetailsFile. |
| 47 | +func NewFromTLSDetails(details *TLSDetailsFile) (*Config, error) { |
| 48 | + res := Config{ |
| 49 | + text: TLSDetailsFile{}, |
| 50 | + config: &tls.Config{}, |
| 51 | + } |
| 52 | + configChanged := false |
| 53 | + for _, fn := range []func(input *TLSDetailsFile) (bool, error){ |
| 54 | + res.parseMinVersion, |
| 55 | + res.parseCipherSuites, |
| 56 | + res.parseNamedGroups, |
| 57 | + } { |
| 58 | + changed, err := fn(details) |
| 59 | + if err != nil { |
| 60 | + return nil, err |
| 61 | + } |
| 62 | + if changed { |
| 63 | + configChanged = true |
| 64 | + } |
| 65 | + } |
| 66 | + |
| 67 | + if !configChanged { |
| 68 | + res.config = nil |
| 69 | + } |
| 70 | + return &res, nil |
| 71 | +} |
| 72 | + |
| 73 | +// tlsVersions maps TLS version strings to their crypto/tls constants. |
| 74 | +// We could use the `tls.VersionName` names, but those are verbose and contain spaces; |
| 75 | +// similarly the OpenShift enum values (“VersionTLS11”) are unergonomic. |
| 76 | +var tlsVersions = map[string]uint16{ |
| 77 | + "1.0": tls.VersionTLS10, |
| 78 | + "1.1": tls.VersionTLS11, |
| 79 | + "1.2": tls.VersionTLS12, |
| 80 | + "1.3": tls.VersionTLS13, |
| 81 | +} |
| 82 | + |
| 83 | +func (c *Config) parseMinVersion(input *TLSDetailsFile) (bool, error) { |
| 84 | + if input.MinVersion == "" { |
| 85 | + return false, nil |
| 86 | + } |
| 87 | + v, ok := tlsVersions[input.MinVersion] |
| 88 | + if !ok { |
| 89 | + return false, fmt.Errorf("unrecognized TLS minimum version %q", input.MinVersion) |
| 90 | + } |
| 91 | + c.text.MinVersion = input.MinVersion |
| 92 | + c.config.MinVersion = v |
| 93 | + return true, nil |
| 94 | +} |
| 95 | + |
| 96 | +// cipherSuitesByName returns a map from cipher suite name to its ID. |
| 97 | +func cipherSuitesByName() map[string]uint16 { |
| 98 | + // The Go standard library uses IANA names and already contains the mapping (for relevant values) |
| 99 | + // sadly we still need to turn it into a lookup map. |
| 100 | + suites := make(map[string]uint16) |
| 101 | + for _, cs := range tls.CipherSuites() { |
| 102 | + suites[cs.Name] = cs.ID |
| 103 | + } |
| 104 | + for _, cs := range tls.InsecureCipherSuites() { |
| 105 | + suites[cs.Name] = cs.ID |
| 106 | + } |
| 107 | + return suites |
| 108 | +} |
| 109 | + |
| 110 | +func (c *Config) parseCipherSuites(input *TLSDetailsFile) (bool, error) { |
| 111 | + if input.CipherSuites == nil { |
| 112 | + return false, nil |
| 113 | + } |
| 114 | + suitesByName := cipherSuitesByName() |
| 115 | + ids := []uint16{} |
| 116 | + for _, name := range input.CipherSuites { |
| 117 | + id, ok := suitesByName[name] |
| 118 | + if !ok { |
| 119 | + return false, fmt.Errorf("unrecognized TLS cipher suite %q", name) |
| 120 | + } |
| 121 | + ids = append(ids, id) |
| 122 | + } |
| 123 | + c.text.CipherSuites = slices.Clone(input.CipherSuites) |
| 124 | + c.config.CipherSuites = ids |
| 125 | + return true, nil |
| 126 | +} |
| 127 | + |
| 128 | +// groupsByName maps curve/group names to their tls.CurveID. |
| 129 | +// The names match IANA TLS Supported Groups registry. |
| 130 | +// |
| 131 | +// Yes, the x25519 names differ in capitalization. |
| 132 | +// Go’s tls.CurveID has a .String() method, but it |
| 133 | +// uses the Go names. |
| 134 | +var groupsByName = map[string]tls.CurveID{ |
| 135 | + "secp256r1": tls.CurveP256, |
| 136 | + "secp384r1": tls.CurveP384, |
| 137 | + "secp521r1": tls.CurveP521, |
| 138 | + "x25519": tls.X25519, |
| 139 | + "X25519MLKEM768": tls.X25519MLKEM768, |
| 140 | +} |
| 141 | + |
| 142 | +func (c *Config) parseNamedGroups(input *TLSDetailsFile) (bool, error) { |
| 143 | + if input.NamedGroups == nil { |
| 144 | + return false, nil |
| 145 | + } |
| 146 | + ids := []tls.CurveID{} |
| 147 | + for _, name := range input.NamedGroups { |
| 148 | + id, ok := groupsByName[name] |
| 149 | + if !ok { |
| 150 | + return false, fmt.Errorf("unrecognized TLS named group %q", name) |
| 151 | + } |
| 152 | + ids = append(ids, id) |
| 153 | + } |
| 154 | + c.text.NamedGroups = slices.Clone(input.NamedGroups) |
| 155 | + c.config.CurvePreferences = ids |
| 156 | + return true, nil |
| 157 | +} |
| 158 | + |
| 159 | +// TLSConfig returns a *tls.Config matching the provided settings. |
| 160 | +// If c contains no settings, it returns nil. |
| 161 | +// Otherwise, the returned *tls.Config is freshly allocated and the caller can modify it as needed. |
| 162 | +func (c *Config) TLSConfig() *tls.Config { |
| 163 | + if c.config == nil { |
| 164 | + return nil |
| 165 | + } |
| 166 | + return c.config.Clone() |
| 167 | +} |
| 168 | + |
| 169 | +// marshaledSerialization is the data we use in MarshalText/UnmarshalText, |
| 170 | +// marshaled using JSON. |
| 171 | +// |
| 172 | +// Note that the file format is using YAML, but we use JSON, to minimize dependencies |
| 173 | +// in backend code where we don't need comments and the brackets are not annoying users. |
| 174 | +type marshaledSerialization struct { |
| 175 | + Version int |
| 176 | + Data TLSDetailsFile |
| 177 | +} |
| 178 | + |
| 179 | +const marshaledSerializationVersion1 = 1 |
| 180 | + |
| 181 | +// MarshalText serializes c to a text representation. |
| 182 | +// |
| 183 | +// The representation is intended to be reasonably stable across updates to c/image, |
| 184 | +// but the consumer must not be older than the producer. |
| 185 | +func (c Config) MarshalText() ([]byte, error) { |
| 186 | + data := marshaledSerialization{ |
| 187 | + Version: marshaledSerializationVersion1, |
| 188 | + Data: c.text, |
| 189 | + } |
| 190 | + return json.Marshal(data) |
| 191 | +} |
| 192 | + |
| 193 | +// UnmarshalText parses the output of MarshalText. |
| 194 | +// |
| 195 | +// The format is otherwise undocumented and we do not promise ongoing compatibility with producers external to this package. |
| 196 | +func (c *Config) UnmarshalText(text []byte) error { |
| 197 | + var data marshaledSerialization |
| 198 | + |
| 199 | + // In the future, this should be an even stricter parser, e.g. refusing duplicate fields |
| 200 | + // and requiring a case-sensitive field name match. |
| 201 | + decoder := json.NewDecoder(bytes.NewReader(text)) |
| 202 | + decoder.DisallowUnknownFields() |
| 203 | + if err := decoder.Decode(&data); err != nil { |
| 204 | + return err |
| 205 | + } |
| 206 | + if decoder.More() { |
| 207 | + return errors.New("unexpected extra data after a JSON object") |
| 208 | + } |
| 209 | + |
| 210 | + if data.Version != marshaledSerializationVersion1 { |
| 211 | + return fmt.Errorf("unsupported version %d", data.Version) |
| 212 | + } |
| 213 | + v, err := NewFromTLSDetails(&data.Data) |
| 214 | + if err != nil { |
| 215 | + return err |
| 216 | + } |
| 217 | + *c = *v |
| 218 | + return nil |
| 219 | +} |
0 commit comments