Logo

Secure registry access with DaggerHow to securely authenticate against OCI registries in your CI/CD pipelines

Sági-Kazár Márk
Sági-Kazár Márk@sagikazarmark
cover

At OpenMeter, we use Dagger to run our programmable CI/CD pipelines in containers. The process includes building and publishing various types of artifacts like Docker images to OCI-compliant registries (GitHub Container registry, etc.). Although Dagger has built-in support for publishing container images, it does not have a universal way to authenticate against registries that's compatible with all tools interacting with OCI registries.

How does OCI registry authentication work?

When interacting with an OCI registry, particularly when pushing artifacts to it, you need to authenticate yourself. I'm fairly confident that most of you have already encountered this in some form, most likely when using docker login to authenticate against Docker Hub.

Although we won't cover all the details here, here is a high-level overview of the authentication flow:

  1. You run docker login
  2. You enter your credentials
  3. Docker CLI sends a request to the registry
  4. Finally, Docker stores your credentials

The authentication process is similar for other registries as well.

One of the main challenges during authenticating is to securely store the credentials. In the early days, Docker simply stored the credentials in plaintext in the ~/.docker/config.json file. Today it uses credential helpers to store credentials securely in your OS's keychain.

Since Docker is the original tool for interacting with OCI registries, most tools have opted to use the same (or a very similar) mechanism for storing credentials.

(If you are interested in more details about how registry authentication works, I gave a talk at the Open Source Summit EU '23. You can find the record here and the slides here.)

How does Dagger handle registry authentication?

Dagger's core abstraction revolves around containers. It has built-in support for building and publishing container images.

Here is an example of how you can publish a container image using Dagger's programmable interface:

// ...
 
dag.Container().
    From("alpine").
    // add some steps
    WithRegistryAuth("ghcr.io", "username", dag.SetSecret("password", "very secret")).
    Publish(ctx, "ghcr.io/myorg/myimage:latest")

Dagger safely handles the credentials during the entire lifecycle of the pipeline (ie. they don't get written to the filesystem). Unfortunately, this security guarantee in Dagger is unique to container images, and it's not available for other types of artifacts like Helm charts for example.

OCI registry authentication for non-container artifacts

Let's take a look at how we can authenticate against an OCI registry when publishing non-container artifacts.

We will use Helm (OCI-compatible Kubernetes package manager) as an example:

// chart .tgz file created by helm package
var chartArchive *File
 
dag.Container().
    From("alpine/helm").
    WithSecretVariable("HELM_PASSWORD", dag.SetSecret("password", "very secret")).
    WithExec([]string{"sh", "-c", "helm registry login ghcr.io --username username --password $HELM_PASSWORD"}).
    // push chart to the registry

In this example, we use the helm registry login command to authenticate against the Helm registry and we pass the password as a command-line argument. Although the credentials are injected into the container using Dagger's secret primitive, we still have a security risk.

This risk lies in Helm writing the credentials to ~/.config/helm/registry/config.json in plaintext (no OS keychain is available in this case), making it possible for someone to extract them from Dagger's layer cache.

The actual exposure depends on how you run Dagger, but it's an undeniable fact that the credentials are stored in plaintext somewhere on the filesystem.

The solution

The solution lies within the problem itself: if the tool (in this case Helm) writes the credentials to a file, it needs to read them from it as well. If the file can be created outside the container and mounted safely for Helm to read, we can avoid storing the credentials on the filesystem.

Fortunately, the registry config format is quite simple:

{
  "auths": {
    "ghcr.io": {
      "auth": "base64(username:password)"
    }
  }
}

You can easily create the contents of this file in a Dagger function.

Here is an example of how you can use it:

// chart .tgz file created by helm package
var chartArchive *File
 
// registry config file contents
const registryConfig string
 
dag.Container().
    From("alpine/helm").
    WithMountedSecret("/root/.config/helm/registry/config.json", dag.SetSecret("config", registryConfig)).
    // push chart to the registry

This solution effectively mitigates the risk of the credentials being exposed by avoiding writing them to the filesystem. One caveat is that we can no longer use the tool's native way of authenticating against the registry (ie. helm registry login), but that's a small price to pay for security.

Taking it a step further

This is a fairly simple solution, but it's not the most comfortable to work with, especially when building reusable components (for example: a Helm module for Dagger).

Wouldn't it be nice if we could use an API similar to Dagger's built-in one for containers?

I thought so too, so I created a Dagger module for this purpose, called registry-config. It takes care of creating the registry config file and mounting it into the container.

Here is how you can integrate it into your module:

First, install registry-config:

dagger install github.com/sagikazarmark/daggerverse/registry-config

Next, add the registry config module to your module (and make sure it's initialized in the module constructor):

type Module struct {
    // ...
 
	// +private
	RegistryConfig *RegistryConfig
}
 
func New() *Module {
	return &Module{
		// ...
 
		RegistryConfig: dag.RegistryConfig(),
	}
}

Next, expose the registry config API in your module:

// Add credentials for a registry.
func (m *Module) WithRegistryAuth(address string, username string, secret *Secret) *Module {
	m.RegistryConfig = m.RegistryConfig.WithRegistryAuth(address, username, secret)
 
	return m
}
 
// Removes credentials for a registry.
func (m *Module) WithoutRegistryAuth(address string) *Module {
	m.RegistryConfig = m.RegistryConfig.WithoutRegistryAuth(address)
 
	return m
}

Finally, use the registry config module in your container:

// use container for actions that need registry credentials
func (m *Module) container() *Container {
	return m.Container.
		With(func(c *Container) *Container {
			return m.RegistryConfig.MountSecret(c, "/root/.docker/config.json")
		})
}

Using Helm as an example again, here is how you can use the module after implementing registry config support:

dag.Helm().
    WithRegistryAuth("ghcr.io", "username", dag.SetSecret("password", "very secret")).
    Publish(/*...*/)

That's it! Now you have a secure way to authenticate against OCI registries for tools like Helm that write credentials to a file.

If you'd like to see the registry config module used in a real module, you can check out my Helm module.

Conclusion

OCI tools like Helm store credentials on the filesystem in plaintext. It poses a security risk that is easy to overlook, especially in CI/CD environments where multiple users have access to the same machine.

Fortunately, with Dagger and the registry-config module, it is easy to create a secure way to authenticate against OCI registries.