diff --git a/README.rst b/README.rst index ce3b47a46..6f0cbb7ab 100644 --- a/README.rst +++ b/README.rst @@ -336,6 +336,14 @@ which tries several authentication methods, in this order: 3. `Managed Identity credentials `_ 4. `Azure CLI credentials `_ + +If you want to force a specific method you can override this with the enviornment variable ``SOPS_AZURE_AUTH_METHOD`` +- ``default`` (same as not setting this variable) +- ``msi`` +- ``azure-cli`` +- ``cached-device-code`` (device code authentication which caches the token in the os keyring) +- ``cached-browser`` (interactive browser authentication which caches the token in the os keyring) + For example, you can use a Service Principal with the following environment variables: .. code:: bash diff --git a/azkv/keysource.go b/azkv/keysource.go index 28cb6ebde..666f1d8fa 100644 --- a/azkv/keysource.go +++ b/azkv/keysource.go @@ -9,14 +9,21 @@ import ( "context" "encoding/base64" "fmt" + "path/filepath" "regexp" "strings" "time" + "encoding/json" + "os" + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + azidentitycache "github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache" "github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys" + "github.com/pkg/browser" "github.com/sirupsen/logrus" "github.com/getsops/sops/v3/logging" @@ -25,6 +32,11 @@ import ( const ( // KeyTypeIdentifier is the string used to identify an Azure Key Vault MasterKey. KeyTypeIdentifier = "azure_kv" + + SopsAzureAuthMethodEnv = "SOPS_AZURE_AUTH_METHOD" + + cachedBrowserAuthRecordFileName = "azure-auth-record-browser.json" + cachedDeviceCodeAuthRecordFileName = "azure-auth-record-device-code.json" ) var ( @@ -230,7 +242,142 @@ func (key *MasterKey) TypeToIdentifier() string { // azidentity.NewDefaultAzureCredential. func (key *MasterKey) getTokenCredential() (azcore.TokenCredential, error) { if key.tokenCredential == nil { - return azidentity.NewDefaultAzureCredential(nil) + + authMethod := strings.ToLower(os.Getenv(SopsAzureAuthMethodEnv)) + switch authMethod { + case "cached-browser": + return cachedInteractiveBrowserCredentials() + case "cached-device-code": + return cachedDeviceCodeCredentials() + case "azure-cli": + return azidentity.NewAzureCLICredential(nil) + case "msi": + return azidentity.NewManagedIdentityCredential(nil) + // If "DEFAULT" or not explicitly specified then use the default authentication chain. + case "", "default": + return azidentity.NewDefaultAzureCredential(nil) + default: + return nil, fmt.Errorf("Value `%s` is unsupported for environment variable `%s`, to resolve this either leave it unset or use one of `default`/`msi`/`azure-cli`/`cached-browser`/`cached-device-code`", authMethod, SopsAzureAuthMethodEnv) + } } return key.tokenCredential, nil } + +func sopsCacheDir() (string, error) { + userCacheDir, err := os.UserCacheDir() + if err != nil { + return "", err + } + + cacheDir := filepath.Join(userCacheDir, "/sops") + + if err = os.MkdirAll(cacheDir, 0o700); err != nil { + return "", err + } + + return cacheDir, nil +} + +type CachableTokenCredential interface { + Authenticate(ctx context.Context, opts *policy.TokenRequestOptions) (azidentity.AuthenticationRecord, error) + GetToken(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error) +} + +func cacheStoreRecord(cachePath string, record azidentity.AuthenticationRecord) error { + b, err := json.Marshal(record) + if err != nil { + return err + } + + return os.WriteFile(cachePath, b, 0600) +} + +func cacheLoadRecord(cachePath string) (azidentity.AuthenticationRecord, error) { + var record azidentity.AuthenticationRecord + + b, err := os.ReadFile(cachePath) + if err != nil { + return record, err + } + + err = json.Unmarshal(b, &record) + if err != nil { + return record, err + } + + return record, nil +} + +func cacheTokenCredential(cachePath string, tokenCredentialFn func(cache azidentity.Cache, record azidentity.AuthenticationRecord) (CachableTokenCredential, error)) (azcore.TokenCredential, error) { + cache, err := azidentitycache.New(nil) + // Errors if persistent caching is not supported by the current runtime + if err != nil { + return nil, err + } + + cachedRecord, cacheLoadErr := cacheLoadRecord(cachePath) + + credential, err := tokenCredentialFn(cache, cachedRecord) + if err != nil { + return nil, err + } + + // If loading the authenticationRecord from the cachePath failed for any reason (validation, file doesn't exist, not encoded using json, etc.) + if cacheLoadErr != nil { + record, err := credential.Authenticate(context.Background(), nil) + if err != nil { + return nil, err + } + + if err = cacheStoreRecord(cachePath, record); err != nil { + return nil, err + } + } + + return credential, nil +} + +func cachedInteractiveBrowserCredentials() (azcore.TokenCredential, error) { + // The default behaviour of `browser` which `azidentity` is using for the interactive browser authentication method is to write anything the browser prints to stdout to the stdout of the program running it. + // This is not desired since on the initial authentication or when refreshing the cache it would pollute the output of sops. + // To fix this behaviour we redirect the browser stdout -> stderr so any pertinent information written by the browser is not completely hidden from the user but it doesn't mess up the sops output. + browser.Stdout = os.Stderr + + cacheDir, err := sopsCacheDir() + if err != nil { + return nil, err + } + + return cacheTokenCredential( + filepath.Join(cacheDir, cachedBrowserAuthRecordFileName), + func(cache azidentity.Cache, record azidentity.AuthenticationRecord) (CachableTokenCredential, error) { + return azidentity.NewInteractiveBrowserCredential(&azidentity.InteractiveBrowserCredentialOptions{ + AuthenticationRecord: record, + Cache: cache, + }) + }, + ) +} + +func cachedDeviceCodeCredentials() (azcore.TokenCredential, error) { + cacheDir, err := sopsCacheDir() + if err != nil { + return nil, err + } + + return cacheTokenCredential( + filepath.Join(cacheDir, cachedDeviceCodeAuthRecordFileName), + func(cache azidentity.Cache, record azidentity.AuthenticationRecord) (CachableTokenCredential, error) { + return azidentity.NewDeviceCodeCredential(&azidentity.DeviceCodeCredentialOptions{ + AuthenticationRecord: record, + Cache: cache, + // Print the device code authentication information to stderr so we don't pollute the output of sops. + UserPrompt: func(ctx context.Context, dc azidentity.DeviceCodeMessage) error { + _, err := fmt.Fprintln(os.Stderr, dc.Message) + return err + + }, + }) + }, + ) +} diff --git a/go.mod b/go.mod index de46586f3..bdc662ac8 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( filippo.io/age v1.2.1 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.2 + github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1 github.com/ProtonMail/go-crypto v1.1.6 github.com/aws/aws-sdk-go-v2 v1.36.3 @@ -29,6 +30,7 @@ require ( github.com/mitchellh/go-homedir v1.1.0 github.com/mitchellh/go-wordwrap v1.0.1 github.com/ory/dockertest/v3 v3.12.0 + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pkg/errors v0.9.1 github.com/sirupsen/logrus v1.9.3 github.com/stretchr/testify v1.10.0 @@ -60,6 +62,7 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 // indirect @@ -111,6 +114,7 @@ require ( github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect github.com/hashicorp/go-sockaddr v1.0.7 // indirect github.com/hashicorp/hcl v1.0.0 // indirect + github.com/keybase/go-keychain v0.0.0-20231219164618-57a3676c3af6 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -121,7 +125,6 @@ require ( github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/opencontainers/runc v1.2.6 // indirect - github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect