diff --git a/docs/release-notes.md b/docs/release-notes.md index 80c9ebe9c..5733ecbab 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -10,6 +10,7 @@ nav_order: 9 ### Features - The name for custom clevis pins is not validated by Ignition anymore, enabling the use of arbitrary custom pins. +- Azure images can opt into `--generate-cloud-config=azure`, letting `ignition-fetch.service` synthesize an admin-user config from IMDS metadata and the provisioning media. ### Changes diff --git a/dracut/30ignition/ignition-generator b/dracut/30ignition/ignition-generator index 1dff74423..446c5fd15 100755 --- a/dracut/30ignition/ignition-generator +++ b/dracut/30ignition/ignition-generator @@ -59,4 +59,24 @@ else fi fi -echo "PLATFORM_ID=$(cmdline_arg ignition.platform.id)" > /run/ignition.env +platform_id="$(cmdline_arg ignition.platform.id)" +ignition_args=() + +# Check if cloud config generation is requested via kernel cmdline +# Usage: ignition.config.generate=azure +generate_config="$(cmdline_arg ignition.config.generate)" +if [[ -n "${generate_config}" ]]; then + # Basic validation to prevent command injection. The value should be a + # simple platform identifier. + if [[ "${generate_config}" =~ ^[a-zA-Z0-9_-]+$ ]]; then + ignition_args+=("--generate-cloud-config=${generate_config}") + else + # Log to stderr, which is redirected to kmsg + echo "ignition-generator: invalid value for ignition.config.generate: ${generate_config}" >&2 + fi +fi + +{ + echo "PLATFORM_ID=${platform_id}" + echo "IGNITION_ARGS=${ignition_args[*]}" +} > /run/ignition.env diff --git a/go.mod b/go.mod index f8b16f836..ab4fa0fbd 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( cloud.google.com/go/storage v1.57.0 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.0 github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3 + github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 github.com/aws/aws-sdk-go-v2 v1.39.3 github.com/aws/aws-sdk-go-v2/credentials v1.18.17 github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10 diff --git a/go.sum b/go.sum index a22d889f2..9ec1365ed 100644 --- a/go.sum +++ b/go.sum @@ -36,6 +36,8 @@ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= github.com/AzureAD/microsoft-authentication-library-for-go v1.5.0 h1:XkkQbfMyuH2jTSjQjSoihryI8GINRcs4xp8lNawg0FI= github.com/AzureAD/microsoft-authentication-library-for-go v1.5.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= +github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 h1:IEjq88XO4PuBDcvmjQJcQGg+w+UaafSy8G5Kcb5tBhI= +github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5/go.mod h1:exZ0C/1emQJAw5tHOaUDyY1ycttqBAPcxuzf7QbY6ec= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 h1:UQUsRi8WTzhZntp5313l+CHIAT95ojUI2lpP/ExlZa4= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0/go.mod h1:Cz6ft6Dkn3Et6l2v2a9/RpN7epQ1GtDlO6lj8bEcOvw= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 h1:owcC2UnmsZycprQ5RfRgjydWhuoxg71LUfyiQdijZuM= diff --git a/internal/exec/engine.go b/internal/exec/engine.go index c2e61f7eb..3aea2ffad 100644 --- a/internal/exec/engine.go +++ b/internal/exec/engine.go @@ -55,14 +55,15 @@ var ( // Engine represents the entity that fetches and executes a configuration. type Engine struct { - ConfigCache string - FetchTimeout time.Duration - Logger *log.Logger - NeedNet string - Root string - PlatformConfig platform.Config - Fetcher *resource.Fetcher - State *state.State + ConfigCache string + FetchTimeout time.Duration + GenerateCloudConfig string + Logger *log.Logger + NeedNet string + Root string + PlatformConfig platform.Config + Fetcher *resource.Fetcher + State *state.State } // Run executes the stage of the given name. It returns true if the stage @@ -286,6 +287,10 @@ func (e *Engine) acquireProviderConfig() (cfg types.Config, err error) { // is unavailable. This will also render the config (see renderConfig) before // returning. func (e *Engine) fetchProviderConfig() (types.Config, error) { + if e.GenerateCloudConfig != "" { + return e.fetchGeneratedConfig() + } + platformConfigs := []platform.Config{ cmdline.Config, system.Config, @@ -331,6 +336,31 @@ func (e *Engine) fetchProviderConfig() (types.Config, error) { return configFetcher.RenderConfig(cfg) } +func (e *Engine) fetchGeneratedConfig() (types.Config, error) { + if e.GenerateCloudConfig != e.PlatformConfig.Name() { + return types.Config{}, fmt.Errorf("cannot generate %q config on %q platform", e.GenerateCloudConfig, e.PlatformConfig.Name()) + } + + cfg, err := e.PlatformConfig.GenerateConfig(e.Fetcher) + if err != nil { + return types.Config{}, err + } + + e.State.FetchedConfigs = append(e.State.FetchedConfigs, state.FetchedConfig{ + Kind: "user", + Source: fmt.Sprintf("%s-generator", e.GenerateCloudConfig), + Referenced: false, + }) + + configFetcher := ConfigFetcher{ + Logger: e.Logger, + Fetcher: e.Fetcher, + State: e.State, + } + + return configFetcher.RenderConfig(cfg) +} + func (e *Engine) signalNeedNet() error { if err := executil.MkdirForFile(e.NeedNet); err != nil { return err diff --git a/internal/main.go b/internal/main.go index 4d636a399..a10e92073 100644 --- a/internal/main.go +++ b/internal/main.go @@ -48,20 +48,22 @@ func main() { func ignitionMain() { flags := struct { - configCache string - fetchTimeout time.Duration - needNet string - platform platform.Name - root string - stage stages.Name - stateFile string - version bool - logToStdout bool + configCache string + fetchTimeout time.Duration + generateCloudConfig string + needNet string + platform platform.Name + root string + stage stages.Name + stateFile string + version bool + logToStdout bool }{} flag.StringVar(&flags.configCache, "config-cache", "/run/ignition.json", "where to cache the config") flag.DurationVar(&flags.fetchTimeout, "fetch-timeout", exec.DefaultFetchTimeout, "initial duration for which to wait for config") flag.StringVar(&flags.needNet, "neednet", "/run/ignition/neednet", "flag file to write from fetch-offline if networking is needed") + flag.StringVar(&flags.generateCloudConfig, "generate-cloud-config", "", "generate a platform-specific config instead of fetching (e.g. azure)") flag.Var(&flags.platform, "platform", fmt.Sprintf("current platform. %v", platform.Names())) flag.StringVar(&flags.root, "root", "/", "root of the filesystem") flag.Var(&flags.stage, "stage", fmt.Sprintf("execution stage. %v", stages.Names())) @@ -104,14 +106,15 @@ func ignitionMain() { os.Exit(3) } engine := exec.Engine{ - Root: flags.root, - FetchTimeout: flags.fetchTimeout, - Logger: &logger, - NeedNet: flags.needNet, - ConfigCache: flags.configCache, - PlatformConfig: platformConfig, - Fetcher: &fetcher, - State: &state, + Root: flags.root, + FetchTimeout: flags.fetchTimeout, + GenerateCloudConfig: flags.generateCloudConfig, + Logger: &logger, + NeedNet: flags.needNet, + ConfigCache: flags.configCache, + PlatformConfig: platformConfig, + Fetcher: &fetcher, + State: &state, } err = engine.Run(flags.stage.String()) diff --git a/internal/platform/platform.go b/internal/platform/platform.go index 9b01df0cc..c12c47991 100644 --- a/internal/platform/platform.go +++ b/internal/platform/platform.go @@ -48,6 +48,8 @@ type Provider struct { Init func(f *resource.Fetcher) error Status func(stageName string, f resource.Fetcher, e error) error DelConfig func(f *resource.Fetcher) error + // Generates an azure-specific Ignition config. + GenerateCloudConfig func(f *resource.Fetcher) (types.Config, error) // Fetch, and also save output files to be written during files stage. // Avoid, unless you're certain you need it. @@ -104,6 +106,13 @@ func (c Config) DelConfig(f *resource.Fetcher) error { } } +func (c Config) GenerateConfig(f *resource.Fetcher) (types.Config, error) { + if c.p.GenerateCloudConfig != nil { + return c.p.GenerateCloudConfig(f) + } + return types.Config{}, ErrNoProvider +} + var configs = registry.Create("platform configs") func Register(provider Provider) { diff --git a/internal/providers/azure/azure.go b/internal/providers/azure/azure.go index 09de9591f..7459ce752 100644 --- a/internal/providers/azure/azure.go +++ b/internal/providers/azure/azure.go @@ -18,21 +18,27 @@ package azure import ( "encoding/base64" + "encoding/json" + "encoding/xml" "fmt" "net/http" "net/url" "os" "path/filepath" + "strconv" + "strings" "time" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/coreos/ignition/v2/config/shared/errors" + cfgutil "github.com/coreos/ignition/v2/config/util" "github.com/coreos/ignition/v2/config/v3_6_experimental/types" execUtil "github.com/coreos/ignition/v2/internal/exec/util" "github.com/coreos/ignition/v2/internal/log" "github.com/coreos/ignition/v2/internal/platform" "github.com/coreos/ignition/v2/internal/providers/util" "github.com/coreos/ignition/v2/internal/resource" + "github.com/vincent-petithory/dataurl" "github.com/coreos/vcontext/report" "golang.org/x/sys/unix" @@ -68,13 +74,26 @@ var ( Path: "metadata/instance/compute/userData", RawQuery: "api-version=2021-01-01&format=text", } + imdsInstanceURL = url.URL{ + Scheme: "http", + Host: "169.254.169.254", + Path: "metadata/instance", + RawQuery: "api-version=2021-01-01&format=json&extended=true", + } ) +var imdsRetryCodes = []int{ + 404, + 410, + 429, +} + func init() { platform.Register(platform.Provider{ - Name: "azure", - NewFetcher: newFetcher, - Fetch: fetchConfig, + Name: "azure", + NewFetcher: newFetcher, + Fetch: fetchConfig, + GenerateCloudConfig: generateCloudConfig, }) } @@ -134,12 +153,7 @@ func fetchFromIMDS(f *resource.Fetcher) ([]byte, error) { // Here, we match the cloud-init set. // https://github.com/canonical/cloud-init/commit/c1a2047cf291 // https://github.com/coreos/ignition/issues/1806 - retryCodes := []int{ - 404, // not found - 410, // gone - 429, // rate-limited - } - data, err := f.FetchToBuffer(imdsUserdataURL, resource.FetchOptions{Headers: headers, RetryCodes: retryCodes}) + data, err := f.FetchToBuffer(imdsUserdataURL, resource.FetchOptions{Headers: headers, RetryCodes: imdsRetryCodes}) if err != nil { return nil, fmt.Errorf("fetching to buffer: %w", err) } @@ -200,42 +214,8 @@ func FetchFromOvfDevice(f *resource.Fetcher, ovfFsTypes []string) (types.Config, // getRawConfig returns the config by mounting the given block device func getRawConfig(f *resource.Fetcher, devicePath string, fstype string) ([]byte, error) { logger := f.Logger - mnt, err := os.MkdirTemp("", "ignition-azure") - if err != nil { - return nil, fmt.Errorf("failed to create temp directory: %v", err) - } - defer func() { - if removeErr := os.Remove(mnt); removeErr != nil { - logger.Warning("failed to remove temp directory %q: %v", mnt, removeErr) - } - }() - - logger.Debug("mounting config device") - if err := logger.LogOp( - func() error { return unix.Mount(devicePath, mnt, fstype, unix.MS_RDONLY, "") }, - "mounting %q at %q", devicePath, mnt, - ); err != nil { - return nil, fmt.Errorf("failed to mount device %q at %q: %v", devicePath, mnt, err) - } - defer func() { - _ = logger.LogOp( - func() error { return unix.Unmount(mnt, 0) }, - "unmounting %q at %q", devicePath, mnt, - ) - }() - - // detect the config drive by looking for a file which is always present - logger.Debug("checking for config drive") - if _, err := os.Stat(filepath.Join(mnt, "ovf-env.xml")); err != nil { - return nil, fmt.Errorf("device %q does not appear to be a config drive: %v", devicePath, err) - } - logger.Debug("reading config") - rawConfig, err := os.ReadFile(filepath.Join(mnt, configPath)) - if err != nil && !os.IsNotExist(err) { - return nil, fmt.Errorf("failed to read config from device %q: %v", devicePath, err) - } - return rawConfig, nil + return readFileFromDevice(f, devicePath, fstype, configPath) } // isCdromPresent verifies if the given config drive is CD-ROM @@ -275,3 +255,291 @@ func isCdromPresent(logger *log.Logger, devicePath string) bool { return (status == CDS_DISC_OK) } + +func readFileFromDevice(f *resource.Fetcher, devicePath string, fstype string, relativePath string) ([]byte, error) { + logger := f.Logger + mnt, err := os.MkdirTemp("", "ignition-azure") + if err != nil { + return nil, fmt.Errorf("failed to create temp directory: %v", err) + } + defer func() { + if removeErr := os.Remove(mnt); removeErr != nil { + logger.Warning("failed to remove temp directory %q: %v", mnt, removeErr) + } + }() + + logger.Debug("mounting config device") + if err := logger.LogOp( + func() error { return unix.Mount(devicePath, mnt, fstype, unix.MS_RDONLY, "") }, + "mounting %q at %q", devicePath, mnt, + ); err != nil { + return nil, fmt.Errorf("failed to mount device %q at %q: %v", devicePath, mnt, err) + } + defer func() { + _ = logger.LogOp( + func() error { return unix.Unmount(mnt, 0) }, + "unmounting %q at %q", devicePath, mnt, + ) + }() + + logger.Debug("checking for config drive") + if _, err := os.Stat(filepath.Join(mnt, "ovf-env.xml")); err != nil { + return nil, fmt.Errorf("device %q does not appear to be a config drive: %v", devicePath, err) + } + + target := filepath.Join(mnt, strings.TrimPrefix(relativePath, "/")) + data, err := os.ReadFile(target) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("failed to read %q from device %q: %v", relativePath, devicePath, err) + } + return data, nil +} + +type instanceMetadata struct { + Compute instanceComputeMetadata `json:"compute"` +} + +type instanceComputeMetadata struct { + Hostname string `json:"hostname"` + OSProfile instanceOSProfile `json:"osProfile"` + PublicKeys []instancePublicKey `json:"publicKeys"` +} + +type instanceOSProfile struct { + AdminUsername string `json:"adminUsername"` +} + +type instancePublicKey struct { + KeyData string `json:"keyData"` +} + +type provisioningEnvelope struct { + LinuxProvisioningConfigurationSet linuxProvisioningConfigurationSet `xml:"LinuxProvisioningConfigurationSet"` +} + +type linuxProvisioningConfigurationSet struct { + HostName string `xml:"HostName"` + UserName string `xml:"UserName"` + UserPassword string `xml:"UserPassword"` + DisableSshPasswordAuthentication string `xml:"DisableSshPasswordAuthentication"` + SSH sshSection `xml:"SSH"` + CustomData string `xml:"CustomData"` + UserData string `xml:"UserData"` +} + +type sshSection struct { + PublicKeys []sshPublicKey `xml:"PublicKeys>PublicKey"` +} + +type sshPublicKey struct { + Value string `xml:"Value"` +} + +func (l linuxProvisioningConfigurationSet) passwordAuthDisabled() bool { + val := strings.ToLower(strings.TrimSpace(l.DisableSshPasswordAuthentication)) + switch val { + case "true", "1", "yes": + return true + case "false", "0", "no", "": + return false + default: + // Try parsing as bool for any other values + disabled, err := strconv.ParseBool(val) + if err != nil { + return false + } + return disabled + } +} + +func generateCloudConfig(f *resource.Fetcher) (types.Config, error) { + meta, err := fetchInstanceMetadata(f) + if err != nil { + return types.Config{}, fmt.Errorf("fetching instance metadata: %w", err) + } + + ovfRaw, err := readOvfEnvironment(f, []string{CDS_FSTYPE_UDF}) + if err != nil { + return types.Config{}, fmt.Errorf("reading provisioning metadata: %w", err) + } + if len(ovfRaw) == 0 { + return types.Config{}, fmt.Errorf("ovf-env.xml was empty") + } + + provisioning, err := parseProvisioningConfig(ovfRaw) + if err != nil { + return types.Config{}, fmt.Errorf("parsing provisioning metadata: %w", err) + } + + return buildGeneratedConfig(meta, provisioning) +} + +func fetchInstanceMetadata(f *resource.Fetcher) (*instanceMetadata, error) { + headers := make(http.Header) + headers.Set("Metadata", "true") + data, err := f.FetchToBuffer(imdsInstanceURL, resource.FetchOptions{Headers: headers, RetryCodes: imdsRetryCodes}) + if err != nil { + return nil, fmt.Errorf("fetching metadata: %w", err) + } + + var meta instanceMetadata + if err := json.Unmarshal(data, &meta); err != nil { + return nil, fmt.Errorf("decoding metadata: %w", err) + } + return &meta, nil +} + +const ( + // maxOvfRetries is the maximum number of attempts to find the OVF environment + maxOvfRetries = 30 + // ovfRetryInterval is the time between retries + ovfRetryInterval = time.Second +) + +func readOvfEnvironment(f *resource.Fetcher, ovfFsTypes []string) ([]byte, error) { + logger := f.Logger + checkedDevices := make(map[string]struct{}) + + for attempt := 0; attempt < maxOvfRetries; attempt++ { + for _, ovfFsType := range ovfFsTypes { + devices, err := execUtil.GetBlockDevices(ovfFsType) + if err != nil { + return nil, fmt.Errorf("failed to retrieve block devices with FSTYPE=%q: %v", ovfFsType, err) + } + for _, dev := range devices { + if _, checked := checkedDevices[dev]; checked { + continue + } + if isCdromPresent(logger, dev) { + data, err := readFileFromDevice(f, dev, ovfFsType, "ovf-env.xml") + if err != nil { + logger.Debug("failed to read ovf environment from device %q: %v", dev, err) + } else if len(data) > 0 { + return data, nil + } + } + checkedDevices[dev] = struct{}{} + } + } + if attempt < maxOvfRetries-1 { + time.Sleep(ovfRetryInterval) + } + } + + return nil, fmt.Errorf("failed to find OVF environment after %d attempts", maxOvfRetries) +} + +func parseProvisioningConfig(raw []byte) (*linuxProvisioningConfigurationSet, error) { + var env provisioningEnvelope + if err := xml.Unmarshal(raw, &env); err != nil { + return nil, err + } + return &env.LinuxProvisioningConfigurationSet, nil +} + +func buildGeneratedConfig(meta *instanceMetadata, provisioning *linuxProvisioningConfigurationSet) (types.Config, error) { + username := strings.TrimSpace(meta.Compute.OSProfile.AdminUsername) + if username == "" { + username = strings.TrimSpace(provisioning.UserName) + } + if username == "" { + return types.Config{}, fmt.Errorf("unable to determine admin username from metadata or provisioning data") + } + + password := strings.TrimSpace(provisioning.UserPassword) + passwordAuthDisabled := provisioning.passwordAuthDisabled() + + sshKeys := collectSSHPublicKeys(meta, provisioning) + + user := types.PasswdUser{ + Name: username, + Groups: []types.Group{"wheel"}, + HomeDir: cfgutil.StrToPtr(fmt.Sprintf("/home/%s", username)), + Shell: cfgutil.StrToPtr("/bin/bash"), + SSHAuthorizedKeys: sshKeys, + } + if password != "" { + // Hash the password if it's not already hashed + var passwordHash string + if IsPasswordHashed(password) { + passwordHash = password + } else { + var err error + passwordHash, err = HashPassword(password) + if err != nil { + return types.Config{}, fmt.Errorf("hashing password: %w", err) + } + } + user.PasswordHash = cfgutil.StrToPtr(passwordHash) + } + + sudoersFile := newDataFile("/etc/sudoers.d/99_wheel_nopasswd", 0440, "%wheel ALL=(ALL) NOPASSWD:ALL\n") + passwordSetting := "yes" + if passwordAuthDisabled { + passwordSetting = "no" + } + sshConfig := fmt.Sprintf(`# Custom SSHD settings +PasswordAuthentication %s +PermitRootLogin no +AllowUsers %s +`, passwordSetting, username) + sshdFile := newDataFile("/etc/ssh/sshd_config.d/10-custom.conf", 0644, sshConfig) + + return types.Config{ + Ignition: types.Ignition{ + Version: types.MaxVersion.String(), + }, + Passwd: types.Passwd{ + Users: []types.PasswdUser{user}, + }, + Storage: types.Storage{ + Files: []types.File{sudoersFile, sshdFile}, + }, + }, nil +} + +func collectSSHPublicKeys(meta *instanceMetadata, provisioning *linuxProvisioningConfigurationSet) []types.SSHAuthorizedKey { + seen := make(map[string]struct{}) + var keys []types.SSHAuthorizedKey + + addKey := func(keyData string) { + key := strings.TrimSpace(keyData) + if key == "" { + return + } + if _, ok := seen[key]; !ok { + seen[key] = struct{}{} + keys = append(keys, types.SSHAuthorizedKey(key)) + } + } + + if meta != nil { + for _, k := range meta.Compute.PublicKeys { + addKey(k.KeyData) + } + } + + if provisioning != nil { + for _, pk := range provisioning.SSH.PublicKeys { + addKey(pk.Value) + } + } + + return keys +} + +func newDataFile(path string, mode int, contents string) types.File { + encoded := dataurl.EncodeBytes([]byte(contents)) + return types.File{ + Node: types.Node{ + Path: path, + }, + FileEmbedded1: types.FileEmbedded1{ + Mode: cfgutil.IntToPtr(mode), + Contents: types.Resource{Source: &encoded}, + }, + } +} diff --git a/internal/providers/azure/azure_test.go b/internal/providers/azure/azure_test.go new file mode 100644 index 000000000..4605a51f2 --- /dev/null +++ b/internal/providers/azure/azure_test.go @@ -0,0 +1,485 @@ +package azure + +import ( + "strings" + "testing" +) + +func TestParseProvisioningConfig(t *testing.T) { + raw := []byte(` + + + myhost + azureuser + password + false + + + + ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCu + + + + +`) + + cfg, err := parseProvisioningConfig(raw) + if err != nil { + t.Fatalf("parseProvisioningConfig() err = %v", err) + } + if cfg.UserName != "azureuser" { + t.Fatalf("expected username azureuser, got %s", cfg.UserName) + } + if len(cfg.SSH.PublicKeys) != 1 { + t.Fatalf("expected 1 ssh key, got %d", len(cfg.SSH.PublicKeys)) + } +} + +func TestBuildGeneratedConfig(t *testing.T) { + meta := &instanceMetadata{ + Compute: instanceComputeMetadata{ + Hostname: "example", + OSProfile: instanceOSProfile{ + AdminUsername: "meta-user", + }, + PublicKeys: []instancePublicKey{ + {KeyData: "ssh-rsa AAAAB3Nza meta"}, + }, + }, + } + prov := &linuxProvisioningConfigurationSet{ + UserName: "prov-user", + SSH: sshSection{ + PublicKeys: []sshPublicKey{ + {Value: "ssh-ed25519 AAAAC3Nza prov"}, + }, + }, + UserPassword: "plaintext", + } + + cfg, err := buildGeneratedConfig(meta, prov) + if err != nil { + t.Fatalf("buildGeneratedConfig() err = %v", err) + } + + if len(cfg.Passwd.Users) != 1 { + t.Fatalf("expected 1 user, got %d", len(cfg.Passwd.Users)) + } + user := cfg.Passwd.Users[0] + if user.Name != "meta-user" { + t.Fatalf("expected user meta-user, got %s", user.Name) + } + if len(user.SSHAuthorizedKeys) != 2 { + t.Fatalf("expected 2 ssh keys, got %d", len(user.SSHAuthorizedKeys)) + } + // Password should be hashed (starts with $6$ for SHA-512) + if user.PasswordHash == nil { + t.Fatalf("expected password hash to be set") + } + if !strings.HasPrefix(*user.PasswordHash, "$6$") { + t.Fatalf("expected password hash to be SHA-512 (start with $6$), got %s", *user.PasswordHash) + } + + if len(cfg.Storage.Files) != 2 { + t.Fatalf("expected 2 files, got %d", len(cfg.Storage.Files)) + } +} + +func TestBuildGeneratedConfigWithPrehashedPassword(t *testing.T) { + // Test that pre-hashed passwords are not double-hashed + prehashedPassword := "$6$rounds=5000$saltsalt$hashedvalue" + meta := &instanceMetadata{ + Compute: instanceComputeMetadata{ + OSProfile: instanceOSProfile{ + AdminUsername: "testuser", + }, + }, + } + prov := &linuxProvisioningConfigurationSet{ + UserPassword: prehashedPassword, + } + + cfg, err := buildGeneratedConfig(meta, prov) + if err != nil { + t.Fatalf("buildGeneratedConfig() err = %v", err) + } + + user := cfg.Passwd.Users[0] + if user.PasswordHash == nil || *user.PasswordHash != prehashedPassword { + t.Fatalf("expected pre-hashed password to be preserved, got %v", user.PasswordHash) + } +} + +func TestCollectSSHPublicKeysDedup(t *testing.T) { + meta := &instanceMetadata{ + Compute: instanceComputeMetadata{ + PublicKeys: []instancePublicKey{ + {KeyData: "ssh-rsa AAAA"}, + {KeyData: "ssh-rsa AAAA"}, + }, + }, + } + prov := &linuxProvisioningConfigurationSet{ + SSH: sshSection{ + PublicKeys: []sshPublicKey{ + {Value: "ssh-rsa BBBB"}, + {Value: "ssh-rsa AAAA"}, + }, + }, + } + keys := collectSSHPublicKeys(meta, prov) + if len(keys) != 2 { + t.Fatalf("expected 2 unique keys, got %d", len(keys)) + } +} + +func TestPasswordAuthDisabledParsing(t *testing.T) { + trueCases := []string{"true", "TRUE", "1", " yes ", "YES"} + for _, tc := range trueCases { + prov := linuxProvisioningConfigurationSet{DisableSshPasswordAuthentication: tc} + if !prov.passwordAuthDisabled() { + t.Fatalf("expected %q to disable password auth", tc) + } + } + falseCases := []string{"false", "0", "no", "", "NO", "False"} + for _, tc := range falseCases { + prov := linuxProvisioningConfigurationSet{DisableSshPasswordAuthentication: tc} + if prov.passwordAuthDisabled() { + t.Fatalf("expected %q to allow password auth", tc) + } + } +} + +func TestNewDataFile(t *testing.T) { + content := "line1\n" + file := newDataFile("/tmp/example", 0640, content) + if file.Path != "/tmp/example" { + t.Fatalf("unexpected path %s", file.Path) + } + if file.Mode == nil || *file.Mode != 0640 { + t.Fatalf("unexpected mode %#v", file.Mode) + } + if file.Contents.Source == nil || !strings.HasPrefix(*file.Contents.Source, "data:") { + t.Fatalf("expected contents to be a data URL, got %v", file.Contents.Source) + } +} + +func TestBuildGeneratedConfigErrors(t *testing.T) { + meta := &instanceMetadata{} + prov := &linuxProvisioningConfigurationSet{} + if _, err := buildGeneratedConfig(meta, prov); err == nil { + t.Fatalf("expected error when username missing") + } +} + +func TestHashPassword(t *testing.T) { + password := "testpassword123" + hash, err := HashPassword(password) + if err != nil { + t.Fatalf("HashPassword() err = %v", err) + } + + // Verify hash format + if !strings.HasPrefix(hash, "$6$") { + t.Fatalf("expected SHA-512 hash prefix $6$, got %s", hash) + } + + // Verify hash has expected structure: $6$$ + parts := strings.Split(hash, "$") + if len(parts) != 4 { + t.Fatalf("expected 4 parts in hash, got %d: %s", len(parts), hash) + } + if parts[1] != "6" { + t.Fatalf("expected algorithm identifier '6', got %s", parts[1]) + } + if len(parts[2]) != 16 { + t.Fatalf("expected 16 character salt, got %d: %s", len(parts[2]), parts[2]) + } + if len(parts[3]) != 86 { + t.Fatalf("expected 86 character hash, got %d: %s", len(parts[3]), parts[3]) + } +} + +func TestHashPasswordDifferentSalts(t *testing.T) { + password := "testpassword123" + hash1, err := HashPassword(password) + if err != nil { + t.Fatalf("HashPassword() err = %v", err) + } + hash2, err := HashPassword(password) + if err != nil { + t.Fatalf("HashPassword() err = %v", err) + } + + // Hashes should be different due to random salt + if hash1 == hash2 { + t.Fatalf("expected different hashes for same password (different salts)") + } +} + +func TestIsPasswordHashed(t *testing.T) { + tests := []struct { + password string + expected bool + }{ + {"$6$salt$hash", true}, + {"$5$salt$hash", true}, + {"$y$salt$hash", true}, + {"$2a$10$hash", true}, + {"$2b$10$hash", true}, + {"$2y$10$hash", true}, + {"$1$salt$hash", true}, + {"plaintext", false}, + {"$invalid", false}, + {"", false}, + } + + for _, tt := range tests { + result := IsPasswordHashed(tt.password) + if result != tt.expected { + t.Errorf("IsPasswordHashed(%q) = %v, expected %v", tt.password, result, tt.expected) + } + } +} + +func TestBuildGeneratedConfigUsernamePriority(t *testing.T) { + // Test that IMDS AdminUsername takes priority over OVF UserName + meta := &instanceMetadata{ + Compute: instanceComputeMetadata{ + OSProfile: instanceOSProfile{ + AdminUsername: "imds-admin", + }, + }, + } + prov := &linuxProvisioningConfigurationSet{ + UserName: "ovf-user", + } + + cfg, err := buildGeneratedConfig(meta, prov) + if err != nil { + t.Fatalf("buildGeneratedConfig() err = %v", err) + } + if cfg.Passwd.Users[0].Name != "imds-admin" { + t.Fatalf("expected IMDS username 'imds-admin' to take priority, got %s", cfg.Passwd.Users[0].Name) + } +} + +func TestBuildGeneratedConfigUsernameFallback(t *testing.T) { + // Test fallback to OVF UserName when IMDS AdminUsername is empty + meta := &instanceMetadata{ + Compute: instanceComputeMetadata{ + OSProfile: instanceOSProfile{ + AdminUsername: "", + }, + }, + } + prov := &linuxProvisioningConfigurationSet{ + UserName: "ovf-user", + } + + cfg, err := buildGeneratedConfig(meta, prov) + if err != nil { + t.Fatalf("buildGeneratedConfig() err = %v", err) + } + if cfg.Passwd.Users[0].Name != "ovf-user" { + t.Fatalf("expected OVF username 'ovf-user' as fallback, got %s", cfg.Passwd.Users[0].Name) + } +} + +func TestBuildGeneratedConfigWhitespaceUsername(t *testing.T) { + // Test that whitespace-only usernames are treated as empty + meta := &instanceMetadata{ + Compute: instanceComputeMetadata{ + OSProfile: instanceOSProfile{ + AdminUsername: " ", + }, + }, + } + prov := &linuxProvisioningConfigurationSet{ + UserName: "ovf-user", + } + + cfg, err := buildGeneratedConfig(meta, prov) + if err != nil { + t.Fatalf("buildGeneratedConfig() err = %v", err) + } + if cfg.Passwd.Users[0].Name != "ovf-user" { + t.Fatalf("expected OVF username when IMDS is whitespace, got %s", cfg.Passwd.Users[0].Name) + } +} + +func TestBuildGeneratedConfigEmptySSHKeys(t *testing.T) { + meta := &instanceMetadata{ + Compute: instanceComputeMetadata{ + OSProfile: instanceOSProfile{ + AdminUsername: "testuser", + }, + PublicKeys: []instancePublicKey{}, + }, + } + prov := &linuxProvisioningConfigurationSet{ + SSH: sshSection{ + PublicKeys: []sshPublicKey{}, + }, + } + + cfg, err := buildGeneratedConfig(meta, prov) + if err != nil { + t.Fatalf("buildGeneratedConfig() err = %v", err) + } + if len(cfg.Passwd.Users[0].SSHAuthorizedKeys) != 0 { + t.Fatalf("expected 0 SSH keys, got %d", len(cfg.Passwd.Users[0].SSHAuthorizedKeys)) + } +} + +func TestBuildGeneratedConfigNoPassword(t *testing.T) { + meta := &instanceMetadata{ + Compute: instanceComputeMetadata{ + OSProfile: instanceOSProfile{ + AdminUsername: "testuser", + }, + }, + } + prov := &linuxProvisioningConfigurationSet{} + + cfg, err := buildGeneratedConfig(meta, prov) + if err != nil { + t.Fatalf("buildGeneratedConfig() err = %v", err) + } + if cfg.Passwd.Users[0].PasswordHash != nil { + t.Fatalf("expected nil password hash when no password provided, got %v", *cfg.Passwd.Users[0].PasswordHash) + } +} + +func TestBuildGeneratedConfigWhitespacePassword(t *testing.T) { + meta := &instanceMetadata{ + Compute: instanceComputeMetadata{ + OSProfile: instanceOSProfile{ + AdminUsername: "testuser", + }, + }, + } + prov := &linuxProvisioningConfigurationSet{ + UserPassword: " ", + } + + cfg, err := buildGeneratedConfig(meta, prov) + if err != nil { + t.Fatalf("buildGeneratedConfig() err = %v", err) + } + + if cfg.Passwd.Users[0].PasswordHash != nil { + t.Fatalf("expected nil password hash for whitespace password, got %v", *cfg.Passwd.Users[0].PasswordHash) + } +} + +func TestParseProvisioningConfigMalformedXML(t *testing.T) { + malformed := []byte(` + + testuser + + `) + + _, err := parseProvisioningConfig(malformed) + if err == nil { + t.Fatalf("expected error for malformed XML") + } +} + +func TestParseProvisioningConfigEmptyXML(t *testing.T) { + empty := []byte(``) + + _, err := parseProvisioningConfig(empty) + if err == nil { + t.Fatalf("expected error for empty XML") + } +} + +func TestParseProvisioningConfigMinimalXML(t *testing.T) { + minimal := []byte(` + + + minimaluser + +`) + + cfg, err := parseProvisioningConfig(minimal) + if err != nil { + t.Fatalf("parseProvisioningConfig() err = %v", err) + } + if cfg.UserName != "minimaluser" { + t.Fatalf("expected username 'minimaluser', got %s", cfg.UserName) + } + if cfg.UserPassword != "" { + t.Fatalf("expected empty password, got %s", cfg.UserPassword) + } + if len(cfg.SSH.PublicKeys) != 0 { + t.Fatalf("expected 0 SSH keys, got %d", len(cfg.SSH.PublicKeys)) + } +} + +func TestParseProvisioningConfigEmptySection(t *testing.T) { + emptySection := []byte(` + + + +`) + + cfg, err := parseProvisioningConfig(emptySection) + if err != nil { + t.Fatalf("parseProvisioningConfig() err = %v", err) + } + if cfg.UserName != "" { + t.Fatalf("expected empty username, got %s", cfg.UserName) + } +} + +func TestCollectSSHPublicKeysNilInputs(t *testing.T) { + keys := collectSSHPublicKeys(nil, &linuxProvisioningConfigurationSet{ + SSH: sshSection{ + PublicKeys: []sshPublicKey{{Value: "ssh-rsa AAAA"}}, + }, + }) + if len(keys) != 1 { + t.Fatalf("expected 1 key with nil metadata, got %d", len(keys)) + } + + keys = collectSSHPublicKeys(&instanceMetadata{ + Compute: instanceComputeMetadata{ + PublicKeys: []instancePublicKey{{KeyData: "ssh-rsa BBBB"}}, + }, + }, nil) + if len(keys) != 1 { + t.Fatalf("expected 1 key with nil provisioning, got %d", len(keys)) + } + + keys = collectSSHPublicKeys(nil, nil) + if len(keys) != 0 { + t.Fatalf("expected 0 keys with both nil, got %d", len(keys)) + } +} + +func TestCollectSSHPublicKeysWhitespaceOnly(t *testing.T) { + meta := &instanceMetadata{ + Compute: instanceComputeMetadata{ + PublicKeys: []instancePublicKey{ + {KeyData: " "}, + {KeyData: "ssh-rsa AAAA"}, + {KeyData: "\t\n"}, + }, + }, + } + prov := &linuxProvisioningConfigurationSet{ + SSH: sshSection{ + PublicKeys: []sshPublicKey{ + {Value: ""}, + {Value: "ssh-rsa BBBB"}, + }, + }, + } + + keys := collectSSHPublicKeys(meta, prov) + if len(keys) != 2 { + t.Fatalf("expected 2 non-empty keys, got %d", len(keys)) + } +} diff --git a/internal/providers/azure/crypt.go b/internal/providers/azure/crypt.go new file mode 100644 index 000000000..8b3d904a6 --- /dev/null +++ b/internal/providers/azure/crypt.go @@ -0,0 +1,46 @@ +// Copyright 2015 CoreOS, Inc. +// +// 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 +// +// http://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 azure +import ( + "strings" + + "github.com/GehirnInc/crypt" + _ "github.com/GehirnInc/crypt/sha512_crypt" // Register SHA-512 crypt +) + +// HashPassword hashes a plaintext password using SHA-512 crypt algorithm. +// Returns a string suitable for /etc/shadow in the format $6$$ +func HashPassword(password string) (string, error) { + crypter := crypt.SHA512.New() + return crypter.Generate([]byte(password), nil) +} + +// IsPasswordHashed checks if a password string appears to already be hashed +// (starts with a known crypt prefix like $6$, $5$, $y$, etc.) +func IsPasswordHashed(password string) bool { + hashPrefixes := []string{ + "$6$", // SHA-512 + "$5$", // SHA-256 + "$y$", // yescrypt + "$2a$", "$2b$", "$2y$", // bcrypt variants + "$1$", // MD5 (deprecated) + } + for _, prefix := range hashPrefixes { + if strings.HasPrefix(password, prefix) { + return true + } + } + return false +} diff --git a/vendor/github.com/GehirnInc/crypt/.travis.yml b/vendor/github.com/GehirnInc/crypt/.travis.yml new file mode 100644 index 000000000..6a63bc8e8 --- /dev/null +++ b/vendor/github.com/GehirnInc/crypt/.travis.yml @@ -0,0 +1,7 @@ +language: go +go: + - 1.6.x + - 1.7.x + - master +script: + - go test -v -race ./... diff --git a/vendor/github.com/GehirnInc/crypt/AUTHORS.md b/vendor/github.com/GehirnInc/crypt/AUTHORS.md new file mode 100644 index 000000000..4490cf22b --- /dev/null +++ b/vendor/github.com/GehirnInc/crypt/AUTHORS.md @@ -0,0 +1,8 @@ +### Initial author + +[Jeramey Crawford](https://github.com/jeramey) + +### Other authors + +- [Jonas mg](https://github.com/kless) +- [Kohei YOSHIDA](https://github.com/yosida95) diff --git a/vendor/github.com/GehirnInc/crypt/LICENSE b/vendor/github.com/GehirnInc/crypt/LICENSE new file mode 100644 index 000000000..7048fecec --- /dev/null +++ b/vendor/github.com/GehirnInc/crypt/LICENSE @@ -0,0 +1,26 @@ +Copyright (c) 2012, Jeramey Crawford +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/vendor/github.com/GehirnInc/crypt/README.rst b/vendor/github.com/GehirnInc/crypt/README.rst new file mode 100644 index 000000000..0608624fc --- /dev/null +++ b/vendor/github.com/GehirnInc/crypt/README.rst @@ -0,0 +1,61 @@ +.. image:: https://travis-ci.org/GehirnInc/crypt.svg?branch=master + :target: https://travis-ci.org/GehirnInc/crypt + +crypt - A password hashing library for Go +========================================= +crypt provides pure golang implementations of UNIX's crypt(3). + +The goal of crypt is to bring a library of many common and popular password +hashing algorithms to Go and to provide a simple and consistent interface to +each of them. As every hashing method is implemented in pure Go, this library +should be as portable as Go itself. + +All hashing methods come with a test suite which verifies their operation +against itself as well as the output of other password hashing implementations +to ensure compatibility with them. + +I hope you find this library to be useful and easy to use! + +Install +------- + +To install crypt, use the *go get* command. + +.. code-block:: sh + + go get github.com/GehirnInc/crypt + + +Usage +----- + +.. code-block:: go + + package main + + import ( + "fmt" + + "github.com/GehirnInc/crypt" + _ "github.com/GehirnInc/crypt/sha256_crypt" + ) + + func main() { + crypt := crypt.SHA256.New() + ret, _ := crypt.Generate([]byte("secret"), []byte("$5$salt")) + fmt.Println(ret) + + err := crypt.Verify(ret, []byte("secret")) + fmt.Println(err) + + // Output: + // $5$salt$kpa26zwgX83BPSR8d7w93OIXbFt/d3UOTZaAu5vsTM6 + // + } + +Documentation +------------- + +The documentation is available on GoDoc_. + +.. _GoDoc: https://godoc.org/github.com/GehirnInc/crypt diff --git a/vendor/github.com/GehirnInc/crypt/common/base64.go b/vendor/github.com/GehirnInc/crypt/common/base64.go new file mode 100644 index 000000000..ee5240e10 --- /dev/null +++ b/vendor/github.com/GehirnInc/crypt/common/base64.go @@ -0,0 +1,59 @@ +// (C) Copyright 2012, Jeramey Crawford . All +// rights reserved. Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package common + +const ( + alphabet = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" +) + +// Base64_24Bit is a variant of Base64 encoding, commonly used with password +// hashing algorithms to encode the result of their checksum output. +// +// The algorithm operates on up to 3 bytes at a time, encoding the following +// 6-bit sequences into up to 4 hash64 ASCII bytes. +// +// 1. Bottom 6 bits of the first byte +// 2. Top 2 bits of the first byte, and bottom 4 bits of the second byte. +// 3. Top 4 bits of the second byte, and bottom 2 bits of the third byte. +// 4. Top 6 bits of the third byte. +// +// This encoding method does not emit padding bytes as Base64 does. +func Base64_24Bit(src []byte) []byte { + if len(src) == 0 { + return []byte{} // TODO: return nil + } + + dstlen := (len(src)*8 + 5) / 6 + dst := make([]byte, dstlen) + + di, si := 0, 0 + n := len(src) / 3 * 3 + for si < n { + val := uint(src[si+2])<<16 | uint(src[si+1])<<8 | uint(src[si]) + dst[di+0] = alphabet[val&0x3f] + dst[di+1] = alphabet[val>>6&0x3f] + dst[di+2] = alphabet[val>>12&0x3f] + dst[di+3] = alphabet[val>>18] + di += 4 + si += 3 + } + + rem := len(src) - si + if rem == 0 { + return dst + } + + val := uint(src[si+0]) + if rem == 2 { + val |= uint(src[si+1]) << 8 + } + + dst[di+0] = alphabet[val&0x3f] + dst[di+1] = alphabet[val>>6&0x3f] + if rem == 2 { + dst[di+2] = alphabet[val>>12] + } + return dst +} diff --git a/vendor/github.com/GehirnInc/crypt/common/doc.go b/vendor/github.com/GehirnInc/crypt/common/doc.go new file mode 100644 index 000000000..8ba84e960 --- /dev/null +++ b/vendor/github.com/GehirnInc/crypt/common/doc.go @@ -0,0 +1,10 @@ +// (C) Copyright 2012, Jeramey Crawford . All +// rights reserved. Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package common contains routines used by multiple password hashing +// algorithms. +// +// Generally, you will never import this package directly. Many of the +// *_crypt packages will import this package if they require it. +package common diff --git a/vendor/github.com/GehirnInc/crypt/common/salt.go b/vendor/github.com/GehirnInc/crypt/common/salt.go new file mode 100644 index 000000000..54372be0f --- /dev/null +++ b/vendor/github.com/GehirnInc/crypt/common/salt.go @@ -0,0 +1,148 @@ +// (C) Copyright 2012, Jeramey Crawford . All +// rights reserved. Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package common + +import ( + "bytes" + "crypto/rand" + "errors" + "strconv" +) + +var ( + ErrSaltPrefix = errors.New("invalid magic prefix") + ErrSaltFormat = errors.New("invalid salt format") + ErrSaltRounds = errors.New("invalid rounds") +) + +const ( + roundsPrefix = "rounds=" +) + +// Salt represents a salt. +type Salt struct { + MagicPrefix []byte + + SaltLenMin int + SaltLenMax int + + RoundsMin int + RoundsMax int + RoundsDefault int +} + +// Generate generates a random salt of a given length. +// +// The length is set thus: +// +// length > SaltLenMax: length = SaltLenMax +// length < SaltLenMin: length = SaltLenMin +func (s *Salt) Generate(length int) []byte { + if length > s.SaltLenMax { + length = s.SaltLenMax + } else if length < s.SaltLenMin { + length = s.SaltLenMin + } + + saltLen := (length * 6 / 8) + if (length*6)%8 != 0 { + saltLen += 1 + } + salt := make([]byte, saltLen) + rand.Read(salt) + + out := make([]byte, len(s.MagicPrefix)+length) + copy(out, s.MagicPrefix) + copy(out[len(s.MagicPrefix):], Base64_24Bit(salt)) + return out +} + +// GenerateWRounds creates a random salt with the random bytes being of the +// length provided, and the rounds parameter set as specified. +// +// The parameters are set thus: +// +// length > SaltLenMax: length = SaltLenMax +// length < SaltLenMin: length = SaltLenMin +// +// rounds < 0: rounds = RoundsDefault +// rounds < RoundsMin: rounds = RoundsMin +// rounds > RoundsMax: rounds = RoundsMax +// +// If rounds is equal to RoundsDefault, then the "rounds=" part of the salt is +// removed. +func (s *Salt) GenerateWRounds(length, rounds int) []byte { + if length > s.SaltLenMax { + length = s.SaltLenMax + } else if length < s.SaltLenMin { + length = s.SaltLenMin + } + if rounds < 0 { + rounds = s.RoundsDefault + } else if rounds < s.RoundsMin { + rounds = s.RoundsMin + } else if rounds > s.RoundsMax { + rounds = s.RoundsMax + } + + saltLen := (length * 6 / 8) + if (length*6)%8 != 0 { + saltLen += 1 + } + salt := make([]byte, saltLen) + rand.Read(salt) + + roundsText := "" + if rounds != s.RoundsDefault { + roundsText = roundsPrefix + strconv.Itoa(rounds) + "$" + } + + out := make([]byte, len(s.MagicPrefix)+len(roundsText)+length) + copy(out, s.MagicPrefix) + copy(out[len(s.MagicPrefix):], []byte(roundsText)) + copy(out[len(s.MagicPrefix)+len(roundsText):], Base64_24Bit(salt)) + return out +} + +func (s *Salt) Decode(raw []byte) (salt []byte, rounds int, isRoundsDef bool, rest []byte, err error) { + tokens := bytes.SplitN(raw, []byte{'$'}, 4) + if len(tokens) < 3 { + err = ErrSaltFormat + return + } + if !bytes.HasPrefix(raw, s.MagicPrefix) { + err = ErrSaltPrefix + return + } + + if bytes.HasPrefix(tokens[2], []byte(roundsPrefix)) { + if len(tokens) < 4 { + err = ErrSaltFormat + return + } + salt = tokens[3] + + rounds, err = strconv.Atoi(string(tokens[2][len(roundsPrefix):])) + if err != nil { + err = ErrSaltRounds + return + } + if rounds < s.RoundsMin { + rounds = s.RoundsMin + } + if rounds > s.RoundsMax { + rounds = s.RoundsMax + } + isRoundsDef = true + } else { + salt = tokens[2] + rounds = s.RoundsDefault + } + if len(salt) > s.SaltLenMax { + salt = salt[0:s.SaltLenMax] + } + + return +} diff --git a/vendor/github.com/GehirnInc/crypt/crypt.go b/vendor/github.com/GehirnInc/crypt/crypt.go new file mode 100644 index 000000000..1b4151f38 --- /dev/null +++ b/vendor/github.com/GehirnInc/crypt/crypt.go @@ -0,0 +1,121 @@ +// (C) Copyright 2013, Jonas mg. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file. + +// Package crypt provides interface for password crypt functions and collects +// common constants. +package crypt + +import ( + "errors" + "strings" + + "github.com/GehirnInc/crypt/common" +) + +var ErrKeyMismatch = errors.New("hashed value is not the hash of the given password") + +// Crypter is the common interface implemented by all crypt functions. +type Crypter interface { + // Generate performs the hashing algorithm, returning a full hash suitable + // for storage and later password verification. + // + // If the salt is empty, a randomly-generated salt will be generated with a + // length of SaltLenMax and number RoundsDefault of rounds. + // + // Any error only can be got when the salt argument is not empty. + Generate(key, salt []byte) (string, error) + + // Verify compares a hashed key with its possible key equivalent. + // Returns nil on success, or an error on failure; if the hashed key is + // diffrent, the error is "ErrKeyMismatch". + Verify(hashedKey string, key []byte) error + + // Cost returns the hashing cost (in rounds) used to create the given hashed + // key. + // + // When, in the future, the hashing cost of a key needs to be increased in + // order to adjust for greater computational power, this function allows one + // to establish which keys need to be updated. + // + // The algorithms based in MD5-crypt use a fixed value of rounds. + Cost(hashedKey string) (int, error) + + // SetSalt sets a different salt. It is used to easily create derivated + // algorithms, i.e. "apr1_crypt" from "md5_crypt". + SetSalt(salt common.Salt) +} + +// Crypt identifies a crypt function that is implemented in another package. +type Crypt uint + +const ( + APR1 Crypt = 1 + iota // import github.com/GehirnInc/crypt/apr1_crypt + MD5 // import github.com/GehirnInc/crypt/md5_crypt + SHA256 // import github.com/GehirnInc/crypt/sha256_crypt + SHA512 // import github.com/GehirnInc/crypt/sha512_crypt + maxCrypt +) + +var crypts = make([]func() Crypter, maxCrypt) + +// New returns new Crypter making the Crypt c. +// New panics if the Crypt c is unavailable. +func (c Crypt) New() Crypter { + if c > 0 && c < maxCrypt { + f := crypts[c] + if f != nil { + return f() + } + } + panic("crypt: requested crypt function is unavailable") +} + +// Available reports whether the Crypt c is available. +func (c Crypt) Available() bool { + return c > 0 && c < maxCrypt && crypts[c] != nil +} + +var cryptPrefixes = make([]string, maxCrypt) + +// RegisterCrypt registers a function that returns a new instance of the given +// crypt function. This is intended to be called from the init function in +// packages that implement crypt functions. +func RegisterCrypt(c Crypt, f func() Crypter, prefix string) { + if c >= maxCrypt { + panic("crypt: RegisterHash of unknown crypt function") + } + crypts[c] = f + cryptPrefixes[c] = prefix +} + +// New returns a new crypter. +func New(c Crypt) Crypter { + return c.New() +} + +// IsHashSupported returns true if hashedKey has a supported prefix. +// NewFromHash will not panic for this hashedKey +func IsHashSupported(hashedKey string) bool { + for i := range cryptPrefixes { + prefix := cryptPrefixes[i] + if crypts[i] != nil && strings.HasPrefix(hashedKey, prefix) { + return true + } + } + + return false +} + +// NewFromHash returns a new Crypter using the prefix in the given hashed key. +func NewFromHash(hashedKey string) Crypter { + for i := range cryptPrefixes { + prefix := cryptPrefixes[i] + if crypts[i] != nil && strings.HasPrefix(hashedKey, prefix) { + crypt := Crypt(uint(i)) + return crypt.New() + } + } + + panic("crypt: unknown crypt function") +} diff --git a/vendor/github.com/GehirnInc/crypt/internal/utils.go b/vendor/github.com/GehirnInc/crypt/internal/utils.go new file mode 100644 index 000000000..2d36e86ab --- /dev/null +++ b/vendor/github.com/GehirnInc/crypt/internal/utils.go @@ -0,0 +1,41 @@ +// Copyright (c) 2015 Kohei YOSHIDA. All rights reserved. +// This software is licensed under the 3-Clause BSD License +// that can be found in LICENSE file. +package internal + +const ( + cleanBytesLen = 64 +) + +var ( + cleanBytes = make([]byte, cleanBytesLen) +) + +func CleanSensitiveData(b []byte) { + l := len(b) + + for ; l > cleanBytesLen; l -= cleanBytesLen { + copy(b[l-cleanBytesLen:l], cleanBytes) + } + + if l > 0 { + copy(b[0:l], cleanBytes[0:l]) + } +} + +func RepeatByteSequence(input []byte, length int) []byte { + var ( + sequence = make([]byte, length) + unit = len(input) + ) + + j := length / unit * unit + for i := 0; i < j; i += unit { + copy(sequence[i:length], input) + } + if j < length { + copy(sequence[j:length], input[0:length-j]) + } + + return sequence +} diff --git a/vendor/github.com/GehirnInc/crypt/sha512_crypt/sha512_crypt.go b/vendor/github.com/GehirnInc/crypt/sha512_crypt/sha512_crypt.go new file mode 100644 index 000000000..1037d73ca --- /dev/null +++ b/vendor/github.com/GehirnInc/crypt/sha512_crypt/sha512_crypt.go @@ -0,0 +1,188 @@ +// (C) Copyright 2012, Jeramey Crawford . All +// rights reserved. Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package sha512_crypt implements Ulrich Drepper's SHA512-crypt password +// hashing algorithm. +// +// The specification for this algorithm can be found here: +// http://www.akkadia.org/drepper/SHA-crypt.txt +package sha512_crypt + +import ( + "bytes" + "crypto/sha512" + "crypto/subtle" + "strconv" + + "github.com/GehirnInc/crypt" + "github.com/GehirnInc/crypt/common" + "github.com/GehirnInc/crypt/internal" +) + +func init() { + crypt.RegisterCrypt(crypt.SHA512, New, MagicPrefix) +} + +const ( + MagicPrefix = "$6$" + SaltLenMin = 1 + SaltLenMax = 16 + RoundsMin = 1000 + RoundsMax = 999999999 + RoundsDefault = 5000 +) + +var _rounds = []byte("rounds=") + +type crypter struct{ Salt common.Salt } + +// New returns a new crypt.Crypter computing the SHA512-crypt password hashing. +func New() crypt.Crypter { + return &crypter{ + common.Salt{ + MagicPrefix: []byte(MagicPrefix), + SaltLenMin: SaltLenMin, + SaltLenMax: SaltLenMax, + RoundsDefault: RoundsDefault, + RoundsMin: RoundsMin, + RoundsMax: RoundsMax, + }, + } +} + +func (c *crypter) Generate(key, salt []byte) (string, error) { + if len(salt) == 0 { + salt = c.Salt.GenerateWRounds(SaltLenMax, RoundsDefault) + } + salt, rounds, isRoundsDef, _, err := c.Salt.Decode(salt) + if err != nil { + return "", err + } + + keyLen := len(key) + saltLen := len(salt) + h := sha512.New() + + // compute sumB + // step 4-8 + h.Write(key) + h.Write(salt) + h.Write(key) + sumB := h.Sum(nil) + + // Compute sumA + // step 1-3, 9-12 + h.Reset() + h.Write(key) + h.Write(salt) + h.Write(internal.RepeatByteSequence(sumB, keyLen)) + for i := keyLen; i > 0; i >>= 1 { + if i%2 == 0 { + h.Write(key) + } else { + h.Write(sumB) + } + } + sumA := h.Sum(nil) + internal.CleanSensitiveData(sumB) + + // Compute seqP + // step 13-16 + h.Reset() + for i := 0; i < keyLen; i++ { + h.Write(key) + } + seqP := internal.RepeatByteSequence(h.Sum(nil), keyLen) + + // Compute seqS + // step 17-20 + h.Reset() + for i := 0; i < 16+int(sumA[0]); i++ { + h.Write(salt) + } + seqS := internal.RepeatByteSequence(h.Sum(nil), saltLen) + + // step 21 + for i := 0; i < rounds; i++ { + h.Reset() + + if i&1 != 0 { + h.Write(seqP) + } else { + h.Write(sumA) + } + if i%3 != 0 { + h.Write(seqS) + } + if i%7 != 0 { + h.Write(seqP) + } + if i&1 != 0 { + h.Write(sumA) + } else { + h.Write(seqP) + } + copy(sumA, h.Sum(nil)) + } + internal.CleanSensitiveData(seqP) + internal.CleanSensitiveData(seqS) + + // make output + buf := bytes.Buffer{} + buf.Grow(len(c.Salt.MagicPrefix) + len(_rounds) + 9 + 1 + len(salt) + 1 + 86) + buf.Write(c.Salt.MagicPrefix) + if isRoundsDef { + buf.Write(_rounds) + buf.WriteString(strconv.Itoa(rounds)) + buf.WriteByte('$') + } + buf.Write(salt) + buf.WriteByte('$') + buf.Write(common.Base64_24Bit([]byte{ + sumA[42], sumA[21], sumA[0], + sumA[1], sumA[43], sumA[22], + sumA[23], sumA[2], sumA[44], + sumA[45], sumA[24], sumA[3], + sumA[4], sumA[46], sumA[25], + sumA[26], sumA[5], sumA[47], + sumA[48], sumA[27], sumA[6], + sumA[7], sumA[49], sumA[28], + sumA[29], sumA[8], sumA[50], + sumA[51], sumA[30], sumA[9], + sumA[10], sumA[52], sumA[31], + sumA[32], sumA[11], sumA[53], + sumA[54], sumA[33], sumA[12], + sumA[13], sumA[55], sumA[34], + sumA[35], sumA[14], sumA[56], + sumA[57], sumA[36], sumA[15], + sumA[16], sumA[58], sumA[37], + sumA[38], sumA[17], sumA[59], + sumA[60], sumA[39], sumA[18], + sumA[19], sumA[61], sumA[40], + sumA[41], sumA[20], sumA[62], + sumA[63], + })) + return buf.String(), nil +} + +func (c *crypter) Verify(hashedKey string, key []byte) error { + newHash, err := c.Generate(key, []byte(hashedKey)) + if err != nil { + return err + } + if subtle.ConstantTimeCompare([]byte(newHash), []byte(hashedKey)) != 1 { + return crypt.ErrKeyMismatch + } + return nil +} + +func (c *crypter) Cost(hashedKey string) (int, error) { + _, rounds, _, _, err := c.Salt.Decode([]byte(hashedKey)) + if err != nil { + return 0, err + } + return rounds, nil +} + +func (c *crypter) SetSalt(salt common.Salt) { c.Salt = salt } diff --git a/vendor/modules.txt b/vendor/modules.txt index 8c5df90d2..b2f65268e 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -123,6 +123,12 @@ github.com/AzureAD/microsoft-authentication-library-for-go/apps/internal/shared github.com/AzureAD/microsoft-authentication-library-for-go/apps/internal/version github.com/AzureAD/microsoft-authentication-library-for-go/apps/managedidentity github.com/AzureAD/microsoft-authentication-library-for-go/apps/public +# github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 +## explicit; go 1.19 +github.com/GehirnInc/crypt +github.com/GehirnInc/crypt/common +github.com/GehirnInc/crypt/internal +github.com/GehirnInc/crypt/sha512_crypt # github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 ## explicit; go 1.23.0 github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp