diff --git a/cmd/notation/cache.go b/cmd/notation/cache.go index fd163df4f..f42fd612c 100644 --- a/cmd/notation/cache.go +++ b/cmd/notation/cache.go @@ -34,7 +34,6 @@ var ( flagLocal, flagUsername, flagPassword, - flagPlainHTTP, }, ArgsUsage: "[reference|manifest_digest]", Action: listCachedSignatures, @@ -62,7 +61,6 @@ var ( flagLocal, flagUsername, flagPassword, - flagPlainHTTP, }, Action: pruneCachedSignatures, } @@ -76,7 +74,6 @@ var ( flagLocal, flagUsername, flagPassword, - flagPlainHTTP, }, Action: removeCachedSignatures, } diff --git a/cmd/notation/common.go b/cmd/notation/common.go index 04161ecc3..473295dc6 100644 --- a/cmd/notation/common.go +++ b/cmd/notation/common.go @@ -6,18 +6,18 @@ var ( flagUsername = &cli.StringFlag{ Name: "username", Aliases: []string{"u"}, - Usage: "username for generic remote access", + Usage: "Username for registry operations", EnvVars: []string{"NOTATION_USERNAME"}, } flagPassword = &cli.StringFlag{ Name: "password", Aliases: []string{"p"}, - Usage: "password for generic remote access", + Usage: "Password for registry operations", EnvVars: []string{"NOTATION_PASSWORD"}, } flagPlainHTTP = &cli.BoolFlag{ Name: "plain-http", - Usage: "remote access via plain HTTP", + Usage: "Registry access via plain HTTP", } flagMediaType = &cli.StringFlag{ Name: "media-type", diff --git a/cmd/notation/list.go b/cmd/notation/list.go index aa5b29602..05400e1c6 100644 --- a/cmd/notation/list.go +++ b/cmd/notation/list.go @@ -15,7 +15,6 @@ var listCommand = &cli.Command{ Flags: []cli.Flag{ flagUsername, flagPassword, - flagPlainHTTP, }, Action: runList, } diff --git a/cmd/notation/login.go b/cmd/notation/login.go new file mode 100644 index 000000000..3d1329817 --- /dev/null +++ b/cmd/notation/login.go @@ -0,0 +1,102 @@ +package main + +import ( + "errors" + "fmt" + "io" + "os" + "strings" + + "github.com/notaryproject/notation/pkg/auth" + "github.com/urfave/cli/v2" + orasauth "oras.land/oras-go/v2/registry/remote/auth" +) + +var loginCommand = &cli.Command{ + Name: "login", + Usage: "Provides credentials for authenticated registry operations", + UsageText: `notation login [options] [server] + +Example - Login with provided username and password: + notation login -u -p registry.example.com + +Example - Login using $NOTATION_USERNAME $NOTATION_PASSWORD variables: + notation login registry.example.com`, + ArgsUsage: "[server]", + Flags: []cli.Flag{ + flagUsername, + flagPassword, + &cli.BoolFlag{ + Name: "password-stdin", + Usage: "Take the password from stdin", + }, + }, + Before: readPassword, + Action: runLogin, +} + +func runLogin(ctx *cli.Context) error { + // initialize + if !ctx.Args().Present() { + return errors.New("no hostname specified") + } + serverAddress := ctx.Args().First() + + if err := validateAuthConfig(ctx, serverAddress); err != nil { + return err + } + + nativeStore, err := auth.GetCredentialsStore(serverAddress) + if err != nil { + return fmt.Errorf("could not get the credentials store: %v", err) + } + // init creds + creds := newCredentialFromInput( + ctx.String(flagUsername.Name), + ctx.String(flagPassword.Name), + ) + if err = nativeStore.Store(serverAddress, creds); err != nil { + return fmt.Errorf("failed to store credentials: %v", err) + } + return nil +} + +func validateAuthConfig(ctx *cli.Context, serverAddress string) error { + registry, err := getRegistryClient(ctx, serverAddress) + if err != nil { + return err + } + return registry.Ping(ctx.Context) +} + +func newCredentialFromInput(username, password string) orasauth.Credential { + c := orasauth.Credential{ + Username: username, + Password: password, + } + if c.Username == "" { + c.RefreshToken = password + } + return c +} + +func readPassword(ctx *cli.Context) error { + if ctx.Bool("password-stdin") { + password, err := readLine() + if err != nil { + return err + } + ctx.Set(flagPassword.Name, password) + } + return nil +} + +func readLine() (string, error) { + passwordBytes, err := io.ReadAll(os.Stdin) + if err != nil { + return "", err + } + password := strings.TrimSuffix(string(passwordBytes), "\n") + password = strings.TrimSuffix(password, "\r") + return password, nil +} diff --git a/cmd/notation/logout.go b/cmd/notation/logout.go new file mode 100644 index 000000000..5eb707210 --- /dev/null +++ b/cmd/notation/logout.go @@ -0,0 +1,28 @@ +package main + +import ( + "errors" + + "github.com/notaryproject/notation/pkg/auth" + "github.com/urfave/cli/v2" +) + +var logoutCommand = &cli.Command{ + Name: "logout", + Usage: "Log out the specified registry hostname", + ArgsUsage: "[server]", + Action: runLogout, +} + +func runLogout(ctx *cli.Context) error { + // initialize + if !ctx.Args().Present() { + return errors.New("no hostname specified") + } + serverAddress := ctx.Args().First() + nativeStore, err := auth.GetCredentialsStore(serverAddress) + if err != nil { + return err + } + return nativeStore.Erase(serverAddress) +} diff --git a/cmd/notation/main.go b/cmd/notation/main.go index e5d653f6e..fd2bea3a4 100644 --- a/cmd/notation/main.go +++ b/cmd/notation/main.go @@ -18,6 +18,9 @@ func main() { Name: "CNCF Notary Project", }, }, + Flags: []cli.Flag{ + flagPlainHTTP, + }, Commands: []*cli.Command{ signCommand, verifyCommand, @@ -28,6 +31,8 @@ func main() { keyCommand, cacheCommand, pluginCommand, + loginCommand, + logoutCommand, }, } if err := app.Run(os.Args); err != nil { diff --git a/cmd/notation/manifest.go b/cmd/notation/manifest.go index 0b527d973..cce70cb87 100644 --- a/cmd/notation/manifest.go +++ b/cmd/notation/manifest.go @@ -37,7 +37,10 @@ func getManifestDescriptorFromReference(ctx *cli.Context, reference string) (not if err != nil { return notation.Descriptor{}, err } - repo := getRepositoryClient(ctx, ref) + repo, err := getRepositoryClient(ctx, ref) + if err != nil { + return notation.Descriptor{}, err + } return repo.Resolve(ctx.Context, ref.ReferenceOrDefault()) } diff --git a/cmd/notation/pull.go b/cmd/notation/pull.go index 9a3b52d36..3d4051dcc 100644 --- a/cmd/notation/pull.go +++ b/cmd/notation/pull.go @@ -26,7 +26,6 @@ var pullCommand = &cli.Command{ flagOutput, flagUsername, flagPassword, - flagPlainHTTP, }, Action: runPull, } diff --git a/cmd/notation/push.go b/cmd/notation/push.go index 3147a67dc..1d5eab871 100644 --- a/cmd/notation/push.go +++ b/cmd/notation/push.go @@ -19,7 +19,6 @@ var pushCommand = &cli.Command{ flagSignature, flagUsername, flagPassword, - flagPlainHTTP, }, Action: runPush, } diff --git a/cmd/notation/registry.go b/cmd/notation/registry.go index f63ec345e..551398add 100644 --- a/cmd/notation/registry.go +++ b/cmd/notation/registry.go @@ -6,9 +6,11 @@ import ( notationregistry "github.com/notaryproject/notation-go/registry" "github.com/notaryproject/notation/internal/version" + loginauth "github.com/notaryproject/notation/pkg/auth" "github.com/notaryproject/notation/pkg/config" "github.com/urfave/cli/v2" "oras.land/oras-go/v2/registry" + "oras.land/oras-go/v2/registry/remote" "oras.land/oras-go/v2/registry/remote/auth" ) @@ -17,10 +19,31 @@ func getSignatureRepository(ctx *cli.Context, reference string) (notationregistr if err != nil { return nil, err } - return getRepositoryClient(ctx, ref), nil + return getRepositoryClient(ctx, ref) } -func getRepositoryClient(ctx *cli.Context, ref registry.Reference) *notationregistry.RepositoryClient { +func getRegistryClient(ctx *cli.Context, serverAddress string) (*remote.Registry, error) { + reg, err := remote.NewRegistry(serverAddress) + if err != nil { + return nil, err + } + + reg.Client, reg.PlainHTTP, err = getAuthClient(ctx, reg.Reference) + if err != nil { + return nil, err + } + return reg, nil +} + +func getRepositoryClient(ctx *cli.Context, ref registry.Reference) (*notationregistry.RepositoryClient, error) { + authClient, plainHTTP, err := getAuthClient(ctx, ref) + if err != nil { + return nil, err + } + return notationregistry.NewRepositoryClient(authClient, ref, plainHTTP), nil +} + +func getAuthClient(ctx *cli.Context, ref registry.Reference) (*auth.Client, bool, error) { var plainHTTP bool if ctx.IsSet(flagPlainHTTP.Name) { plainHTTP = ctx.Bool(flagPlainHTTP.Name) @@ -42,6 +65,13 @@ func getRepositoryClient(ctx *cli.Context, ref registry.Reference) *notationregi RefreshToken: cred.Password, } } + if cred == auth.EmptyCredential { + var err error + if cred, err = getSavedCreds(ref.Registry); err != nil { + return nil, false, err + } + } + authClient := &auth.Client{ Credential: func(ctx context.Context, registry string) (auth.Credential, error) { switch registry { @@ -56,5 +86,14 @@ func getRepositoryClient(ctx *cli.Context, ref registry.Reference) *notationregi } authClient.SetUserAgent("notation/" + version.GetVersion()) - return notationregistry.NewRepositoryClient(authClient, ref, plainHTTP) + return authClient, plainHTTP, nil +} + +func getSavedCreds(serverAddress string) (auth.Credential, error) { + nativeStore, err := loginauth.GetCredentialsStore(serverAddress) + if err != nil { + return auth.EmptyCredential, err + } + + return nativeStore.Get(serverAddress) } diff --git a/cmd/notation/sign.go b/cmd/notation/sign.go index 31c51c920..47104f218 100644 --- a/cmd/notation/sign.go +++ b/cmd/notation/sign.go @@ -36,7 +36,6 @@ var signCommand = &cli.Command{ }, flagUsername, flagPassword, - flagPlainHTTP, flagMediaType, cmd.FlagPluginConfig, }, diff --git a/cmd/notation/verify.go b/cmd/notation/verify.go index 085cdec45..ad4ad0492 100644 --- a/cmd/notation/verify.go +++ b/cmd/notation/verify.go @@ -40,7 +40,6 @@ var verifyCommand = &cli.Command{ flagLocal, flagUsername, flagPassword, - flagPlainHTTP, flagMediaType, }, Action: runVerify, diff --git a/go.mod b/go.mod index 61367697e..5d2e5af30 100644 --- a/go.mod +++ b/go.mod @@ -5,22 +5,22 @@ go 1.18 require ( github.com/distribution/distribution/v3 v3.0.0-20210804104954-38ab4c606ee3 github.com/docker/cli v20.10.17+incompatible + github.com/docker/docker-credential-helpers v0.6.4 github.com/notaryproject/notation-core-go v0.0.0-20220712013708-3c4b3efa03c5 github.com/notaryproject/notation-go v0.9.0-alpha.1.0.20220712175603-962d79cd4090 github.com/opencontainers/go-digest v1.0.0 github.com/spf13/cobra v1.5.0 github.com/spf13/pflag v1.0.5 github.com/urfave/cli/v2 v2.11.0 - oras.land/oras-go/v2 v2.0.0-20220620164807-8b2a54608a94 + oras.land/oras-go/v2 v2.0.0-20220620164807-8b2a54608a94 // TODO: upgrade to v2.0.0-rc.1 in the next PR ) require ( github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/docker/docker v20.10.8+incompatible // indirect - github.com/docker/docker-credential-helpers v0.6.4 // indirect github.com/golang-jwt/jwt/v4 v4.4.2 // indirect github.com/inconshreveable/mousetrap v1.0.0 // indirect - github.com/opencontainers/image-spec v1.0.2 // indirect + github.com/opencontainers/image-spec v1.0.3-0.20211202183452-c5a74bcca799 // indirect github.com/oras-project/artifacts-spec v1.0.0-rc.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect diff --git a/go.sum b/go.sum index 492594e5f..ba344e8ae 100644 --- a/go.sum +++ b/go.sum @@ -81,8 +81,8 @@ github.com/notaryproject/notation-go v0.9.0-alpha.1.0.20220712175603-962d79cd409 github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= -github.com/opencontainers/image-spec v1.0.2 h1:9yCKha/T5XdGtO0q9Q9a6T5NUCsTn/DrBg0D7ufOcFM= -github.com/opencontainers/image-spec v1.0.2/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= +github.com/opencontainers/image-spec v1.0.3-0.20211202183452-c5a74bcca799 h1:rc3tiVYb5z54aKaDfakKn0dDjIyPpTtszkjuMzyt7ec= +github.com/opencontainers/image-spec v1.0.3-0.20211202183452-c5a74bcca799/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= github.com/oras-project/artifacts-spec v1.0.0-rc.1 h1:bCHf9mPbrgiNwQFyVzBX79BYZVAl0OUrmvICZOCOwts= github.com/oras-project/artifacts-spec v1.0.0-rc.1/go.mod h1:Xch2aLzSwtkhbFFN6LUzTfLtukYvMMdXJ4oZ8O7BOdc= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= diff --git a/pkg/auth/api.go b/pkg/auth/api.go new file mode 100644 index 000000000..bf5c6cb7b --- /dev/null +++ b/pkg/auth/api.go @@ -0,0 +1,13 @@ +package auth + +import "oras.land/oras-go/v2/registry/remote/auth" + +// CredentialStore is the interface that any credentials store must implement. +type CredentialStore interface { + // Store saves credentials into the store + Store(serverAddress string, credsConf auth.Credential) error + // Erase removes credentials from the store for the given server + Erase(serverAddress string) error + // Get retrieves credentials from the store for the given server + Get(serverAddress string) (auth.Credential, error) +} diff --git a/pkg/auth/credential.go b/pkg/auth/credential.go new file mode 100644 index 000000000..54e514e70 --- /dev/null +++ b/pkg/auth/credential.go @@ -0,0 +1,67 @@ +package auth + +import ( + "fmt" + + "github.com/docker/docker-credential-helpers/credentials" + "github.com/notaryproject/notation/pkg/config" + "oras.land/oras-go/v2/registry/remote/auth" +) + +// var for unit tests +var ( + loadOrDefault = config.LoadOrDefault + loadDockerConfig = config.LoadDockerConfig +) + +// LoadConfig loads the configuration from the config file +func LoadConfig() (*config.File, error) { + // load notation config first + config, err := loadOrDefault() + if err != nil { + return nil, err + } + if config != nil && containsAuth(config) { + return config, nil + } + + config, err = loadDockerCredentials() + if err != nil { + return nil, err + } + if containsAuth(config) { + return config, nil + } + return nil, fmt.Errorf("credentials store config is not set up") +} + +// loadDockerCredentials loads the configuration from the config file under .docker +// directory +func loadDockerCredentials() (*config.File, error) { + dockerConfig, err := loadDockerConfig() + if err != nil { + return nil, err + } + return &config.File{ + CredentialHelpers: dockerConfig.CredentialHelpers, + CredentialsStore: dockerConfig.CredentialsStore, + }, nil +} + +// containsAuth returns whether there is authentication configured in this file +// or not. +func containsAuth(configFile *config.File) bool { + return configFile.CredentialsStore != "" || len(configFile.CredentialHelpers) > 0 +} + +// newCredentialFromDockerCreds creates a new auth.Credential from the docker-cli credentials +func newCredentialFromDockerCreds(dockerCreds *credentials.Credentials) auth.Credential { + var credsConf auth.Credential + if dockerCreds.Username == tokenUsername { + credsConf.RefreshToken = dockerCreds.Secret + } else { + credsConf.Password = dockerCreds.Secret + credsConf.Username = dockerCreds.Username + } + return credsConf +} diff --git a/pkg/auth/native_store.go b/pkg/auth/native_store.go new file mode 100644 index 000000000..adfc6c022 --- /dev/null +++ b/pkg/auth/native_store.go @@ -0,0 +1,93 @@ +package auth + +import ( + "fmt" + + "github.com/docker/docker-credential-helpers/client" + "github.com/docker/docker-credential-helpers/credentials" + "github.com/notaryproject/notation/pkg/config" + "oras.land/oras-go/v2/registry/remote/auth" +) + +const ( + remoteCredentialsPrefix = "docker-credential-" + tokenUsername = "" +) + +// var for unit testing. +var loadConfig = LoadConfig + +// nativeAuthStore implements a credentials store using native keychain to keep +// credentials secure. +type nativeAuthStore struct { + programFunc client.ProgramFunc +} + +// GetCredentialsStore returns a new credentials store from the settings in the +// configuration file +func GetCredentialsStore(registryHostname string) (CredentialStore, error) { + configFile, err := loadConfig() + if err != nil { + return nil, fmt.Errorf("failed to load config file, error: %v", err) + } + if helper := getConfiguredCredentialStore(configFile, registryHostname); helper != "" { + return newNativeAuthStore(helper), nil + } + return nil, fmt.Errorf("could not get the configured credentials store for registry: %s", registryHostname) +} + +// newNativeAuthStore creates a new native store that uses a remote helper +// program to manage credentials. Note: it's different from the nativeStore in +// docker-cli which may fall back to plain text store +func newNativeAuthStore(helperSuffix string) CredentialStore { + name := remoteCredentialsPrefix + helperSuffix + return &nativeAuthStore{ + programFunc: client.NewShellProgramFunc(name), + } +} + +// getConfiguredCredentialStore returns the credential helper configured for the +// given registry, the default credsStore, or the empty string if neither are +// configured. +func getConfiguredCredentialStore(c *config.File, registryHostname string) string { + if c.CredentialHelpers != nil && registryHostname != "" { + if helper, exists := c.CredentialHelpers[registryHostname]; exists { + return helper + } + } + return c.CredentialsStore +} + +// Store saves credentials into the native store +func (s *nativeAuthStore) Store(serverAddress string, authCreds auth.Credential) error { + creds := &credentials.Credentials{ + ServerURL: serverAddress, + Username: authCreds.Username, + Secret: authCreds.Password, + } + + if authCreds.RefreshToken != "" { + creds.Username = tokenUsername + creds.Secret = authCreds.RefreshToken + } + + return client.Store(s.programFunc, creds) +} + +// Get retrieves credentials from the store for the given server +func (s *nativeAuthStore) Get(serverAddress string) (auth.Credential, error) { + creds, err := client.Get(s.programFunc, serverAddress) + if err != nil { + if credentials.IsErrCredentialsNotFound(err) { + // do not return an error if the credentials are not in the keychain. + return auth.EmptyCredential, nil + } + return auth.EmptyCredential, err + } + return newCredentialFromDockerCreds(creds), nil +} + +// Erase removes credentials from the store for the given server +func (s *nativeAuthStore) Erase(serverAddress string) error { + return client.Erase(s.programFunc, serverAddress) +} diff --git a/pkg/config/config.go b/pkg/config/config.go index fc1bf1873..16d59b759 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -48,6 +48,8 @@ type File struct { VerificationCertificates VerificationCertificates `json:"verificationCerts"` SigningKeys SigningKeys `json:"signingKeys,omitempty"` InsecureRegistries []string `json:"insecureRegistries"` + CredentialsStore string `json:"credsStore,omitempty"` + CredentialHelpers map[string]string `json:"credHelpers,omitempty"` } // VerificationCertificates is a collection of public certs used for verification. diff --git a/pkg/config/docker_config.go b/pkg/config/docker_config.go new file mode 100644 index 000000000..3f5c66c92 --- /dev/null +++ b/pkg/config/docker_config.go @@ -0,0 +1,69 @@ +package config + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" +) + +const ( + // dockerConfigFileName is the name of config file + dockerConfigFileName = "config.json" + dockerConfigFileDir = ".docker" +) + +// DockerConfigFile is the minimized configuration of the Docker daemon, only +// credentails store related configs are included +type DockerConfigFile struct { + CredentialsStore string `json:"credsStore,omitempty"` + CredentialHelpers map[string]string `json:"credHelpers,omitempty"` +} + +// Load reads the configuration files in the given directory, and sets up +// the auth config information and returns values. +func LoadDockerConfig() (*DockerConfigFile, error) { + configDir, err := getDockerConfigDir() + if err != nil { + return nil, err + } + + filename := filepath.Join(configDir, dockerConfigFileName) + + // load latest config file + file, err := os.Open(filename) + if err != nil { + return nil, fmt.Errorf("%s: %w", filename, err) + } + defer file.Close() + + configFile := &DockerConfigFile{} + err = configFile.loadFromReader(file) + if err != nil { + return nil, fmt.Errorf("%s: %w", filename, err) + } + return configFile, err +} + +func getDockerConfigDir() (string, error) { + configDir := os.Getenv("DOCKER_CONFIG") + if configDir == "" { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("%s, %w", "Could not get home directory", err) + } + configDir = filepath.Join(homeDir, dockerConfigFileDir) + } + return configDir, nil +} + +// loadFromReader reads the configuration data given and sets up the auth config +// information with given directory and populates the receiver object +func (configFile *DockerConfigFile) loadFromReader(configData io.Reader) error { + if err := json.NewDecoder(configData).Decode(configFile); err != nil && !errors.Is(err, io.EOF) { + return err + } + return nil +}