RSS

Notation signatures as ORAS and OCI artifacts

Notary and Notation

Notary project is a set of tools that helps you sign, store, and verify OCI artifacts using OCI-conformant registries.

Notation is an implementation of the Notary v2 specifications. As an implementation provides a CLI that adds signatures as standard items in the registry ecosystem, and can build a set of simple tooling for signing and verifying these signatures.

Notary v2 provides for multiple signatures of an OCI Artifact (including container images) to be persisted in an OCI conformant registry. Artifacts are signed with private keys, and validated with public keys.

To support user deployment flows, signing an OCI Artifact will not change the @digest or artifact :tag reference. To support content movement across multiple certification boundaries, artifacts and their signatures will be easily copied within and across OCI conformant registries.

To deliver on the Notary v2 goals of cross registry movement of artifacts with their signatures, changes to several projects are anticipated, including OCI distribution-spec, CNCF Distribution, OCI Artifacts, ORAS with further consumption from projects (e.g. containerd).

Already changes are coming in ORAS that unified the ORAS artifact spec into the new OCI artifact spec, to cover scenarios where images aren’t the only artifact to be distributed, such as signatures, SBOMs, attestation, etc. but that references container-related artifacts.

OCI and ORAS

Notation leverages ORAS to store signatures into OCI registries. The ORAS project is a set of tools and libraries that enable to use OCI registries to store arbitray artifacts. But what are OCI Registries?

The Open Container Initiative (OCI) defines the specifications and standards for container technologies. This includes the OCI Distribution Specification, the API for working with container registries. Registries that implement the distribution-spec are referred to as OCI Registries.

OCI and ORAS artifacts

The main OCI artifact type is the OCI image. With time, people used registries to store arbitrary artifacts, leveraging performance, security and reliability capabilities of registries. One growing example is artifacts for securing the sofware supply chain like SBOMS, signatures, attestations, scan results, etc.

The OCI artifacts project aims to generalize the artifact types that can be distributed by and stored into OCI registries. The image manifest has a config.mediaType field to differentiate between the various types of artifacts. This field is supposed to be filled by the authors of new artifact types, so ORAS did to support a wide range of artifact types.

It introduced the ORAS artifact specification and related application/vnd.cncf.oras.artifact.manifest.v1+json mediaType. This media type bases on the OCI image manifest but removes constraints such as a required config object and required & ordinal layers (more on the OCI image manifest spec here).

The ORAS artifact manifest adds a subject property supporting a graph of independently linked artifacts. It provides a means to define artifacts that can be related to an OCI image manifest, OCI image index or another ORAS artifact manifest (for example here a Notary V2 signature that references an image). By defining a new manifest, registries and clients opt-into new capabilities, without breaking existing behaviour, such as discovery provided by the ORAS referrers API.

Quickstart

Requirements

Run the demo

Run a local ORAS OCI regsitry:

export PORT=5000
export REGISTRY=localhost:${PORT}

docker run -d -p ${PORT}:5000 ghcr.io/oras-project/registry:v0.0.3-alpha

Build and push an OCI image to the local registry with a tag:

export REPO=${REGISTRY}/net-monitor
export IMAGE=${REPO}:v1

docker build -t $IMAGE https://github.com/wabbit-networks/net-monitor.git#main
docker push $IMAGE

See how the image is not signed (the are no signing artifacts on the local repository that reference the just pushed image):

notation list --plain-http $IMAGE

Generate a certificate key pair to sign the image:

notation cert generate-test --default "wabbit-networks.io"

Sign the image with the certificate key just created:

notation sign --plain-http $IMAGE

Now you need to configure the trust policy to specify trusted identities which sign the artifacts, and level of signature verification to use:

cat <<"EOF" > ~/.config/notation/trustpolicy.json
{
    "version": "1.0",
    "trustPolicies": [
        {
            "name": "wabbit-networks-images",
            "registryScopes": [ "*" ],
            "signatureVerification": {
                "level" : "strict" 
            },
            "trustStores": [ "ca:wabbit-networks.io" ],
            "trustedIdentities": [
                "*"
            ]
        }
    ]
}
EOF

Verify that the image is signed, against the trust store.

notation verify --plain-http $IMAGE

But now, let’s get more detail and see what is a signature.

Inpspect the signature artifacts

As of now of Notation v0.12 a signature is an ORAS artifact-spec compatible OCI image, on an OCI registry that references an OCI image. As a digest makes unique an artifact (i.e. an image), the subject field of the signature image manifest references the signing content.

Let’s check that on our local registry!

Inspect with ORAS CLI

First, install a ORAS CLI release with version < 0.16.0.

Later we’ll see why not 0.16.

$ oras discover $IMAGE -o json
{
  "referrers": [
    {
      "digest": "sha256:6131e049f4e045614d575ade11e9c9b44e6b7fb081fdd0b8a27f1726329eb5ab",
      "mediaType": "application/vnd.cncf.oras.artifact.manifest.v1+json",
      "artifactType": "application/vnd.cncf.notary.v2.signature",
      "size": 512
    },
    {
      "digest": "sha256:cdb664bc205fccbfc06cff7310ea42fe8cf483deb2c9e77a3c829c5d3ecde037",
      "mediaType": "application/vnd.cncf.oras.artifact.manifest.v1+json",
      "artifactType": "application/vnd.cncf.notary.v2.signature",
      "size": 512
    }
  ]
}

And you can see that the artifacts digests match the signatures pushed by Notation.

Now let’s see how is composed a application/vnd.cncf.notary.v2.signature manifest, by picking the first signature:

$ oras manifest fetch ${REPO}@$(oras discover $IMAGE -o json | jq -r '.referrers[0].digest') \
  | jq
{
  "mediaType": "application/vnd.cncf.oras.artifact.manifest.v1+json",
  "artifactType": "application/vnd.cncf.notary.v2.signature",
  "blobs": [
    {
      "mediaType": "application/jose+json",
      "digest": "sha256:187e7739f84c8b7770dacfda80917ac1c671b92de192bdadf16c87ca0611d846",
      "size": 2120
    }
  ],
  "subject": {
    "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
    "digest": "sha256:79cf36c749e0e7445335567b97719bddaf57d0f465f9f36bcbe7ce0a25d02ec6",
    "size": 942
  },
  "annotations": {
    "io.cncf.oras.artifact.created": "2022-11-14T18:38:51Z"
  }
}

As you can see the subject field references the Docker image manifest v2 of the signed image.

Show me the code

Now let’s see what the notation sign command does. runSign() is the core part of the command:

func runSign(command *cobra.Command, cmdOpts *signOpts) error {
	// initialize
	signer, err := cmd.GetSigner(&cmdOpts.SignerFlagOpts)
	if err != nil {
		return err
	}

	// core process
	desc, opts, err := prepareSigningContent(command.Context(), cmdOpts)
	if err != nil {
		return err
	}
	sig, err := signer.Sign(command.Context(), desc, opts)
	if err != nil {
		return err
	}

	// write out
	path := cmdOpts.output
	if path == "" {
		path = dir.Path.CachedSignature(digest.Digest(desc.Digest), digest.FromBytes(sig))
	}
	if err := osutil.WriteFile(path, sig); err != nil {
		return err
	}

	if ref := cmdOpts.pushReference; cmdOpts.push && !(cmdOpts.Local && ref == "") {
		if ref == "" {
			ref = cmdOpts.reference
		}
		if _, err := pushSignature(command.Context(), &cmdOpts.SecureFlagOpts, ref, sig); err != nil {
			return fmt.Errorf("fail to push signature to %q: %v: %v",
				ref,
				desc.Digest,
				err,
			)
		}
	}

	fmt.Println(desc.Digest)
	return nil
}

First, a signer is fetched. A signer here is a component that signs an artifact and generate and signature.

prepareSigningContent() prepares the manifest descriptor to be signed:

func prepareSigningContent(ctx context.Context, opts *signOpts) (notation.Descriptor, notation.SignOptions, error) {
	manifestDesc, err := getManifestDescriptorFromContext(ctx, &opts.RemoteFlagOpts, opts.reference)
	if err != nil {
		return notation.Descriptor{}, notation.SignOptions{}, err
	}
	if identity := opts.originReference; identity != "" {
		manifestDesc.Annotations = map[string]string{
			"identity": identity,
		}
	}
	var tsa timestamp.Timestamper
	if endpoint := opts.timestamp; endpoint != "" {
		if tsa, err = timestamp.NewHTTPTimestamper(nil, endpoint); err != nil {
			return notation.Descriptor{}, notation.SignOptions{}, err
		}
	}
	pluginConfig, err := cmd.ParseFlagPluginConfig(opts.pluginConfig)
	if err != nil {
		return notation.Descriptor{}, notation.SignOptions{}, err
	}
	return manifestDesc, notation.SignOptions{
		Expiry:       cmd.GetExpiry(opts.expiry),
		TSA:          tsa,
		PluginConfig: pluginConfig,
	}, nil
}

the signer signs artifacts and generates signatures by delegating the one or more operations to the named plugin that will only generate a raw signature given a payload to sign.

// Sign signs the artifact described by its descriptor and returns the marshalled envelope.
func (s *pluginSigner) Sign(ctx context.Context, desc notation.Descriptor, opts notation.SignOptions) ([]byte, error) {
	metadata, err := s.getMetadata(ctx)
	if err != nil {
		return nil, err
	}
	if !metadata.SupportsContract(plugin.ContractVersion) {
		return nil, fmt.Errorf(
			"contract version %q is not in the list of the plugin supported versions %v",
			plugin.ContractVersion, metadata.SupportedContractVersions,
		)
	}
	if metadata.HasCapability(plugin.CapabilitySignatureGenerator) {
		return s.generateSignature(ctx, desc, opts)
	} else if metadata.HasCapability(plugin.CapabilityEnvelopeGenerator) {
		return s.generateSignatureEnvelope(ctx, desc, opts)
	}
	return nil, fmt.Errorf("plugin does not have signing capabilities")
}

Finally, Notation will package this signature into a signature envelope, and generate and pushes the signature manifest, through pushSignature():

func pushSignature(ctx context.Context, opts *SecureFlagOpts, ref string, sig []byte) (notation.Descriptor, error) {
	// initialize
	sigRepo, err := getSignatureRepository(opts, ref)
	if err != nil {
		return notation.Descriptor{}, err
	}
	manifestDesc, err := getManifestDescriptorFromReference(ctx, opts, ref)
	if err != nil {
		return notation.Descriptor{}, err
	}

	// core process
	// pass in nonempty annotations if needed
	sigMediaType, err := envelope.SpeculateSignatureEnvelopeFormat(sig)
	if err != nil {
		return notation.Descriptor{}, err
	}
	sigDesc, _, err := sigRepo.PutSignatureManifest(ctx, sig, sigMediaType, manifestDesc, make(map[string]string))
	if err != nil {
		return notation.Descriptor{}, fmt.Errorf("put signature manifest failure: %v", err)
	}

	return sigDesc, nil
}

Signing protocols: JOSE and COSE

As a detail, the supported signing protocols are JWS and COSE.

The JOSE Working Group produced a set of documents (RFC7515, RFC7516, RFC7517, RFC7518) that specified how to process encryption, signatures, and Message Authentication Code (MAC) operations and how to encode keys using JSON (like for JWS).

JWS represents content secured with digital signatures or Message Authentication Codes (MACs) using JSON-based data structures.

COSE describes how to create and process signatures, message authentication codes, and encryption using CBOR for serialization. CBOR is a data format that was designed specifically to be small in terms of both messages transported and implementation size and to have a schema-free decoder.

CBOR extended the data model of JavaScript Object Notation (JSON) by allowing for binary data directly without first converting it into a base64-encoded text string, among other changes.

COSE is not a direct copy of the JOSE specification. In the process of creating COSE, decisions that were made for JOSE were re-examined.

What’s next

It happened that ORAS worked to unify their artifact specification into a new OCI standard specification (Reference Types for image and distribution specs).

We’re waiting to see a bump in the Notation Go library to support the new Reference Type (and then Notation CLI) of the ORAS Go library (now release candidate v2.0.0-rc.4).

ORAS CLI v0.16.0 already leverages OCI artifacts, and that’s why in this demonstration we picked a previous version, as we demonstrate ORAS Artifact-based signatures.

See you soon with updates on OCI Artifact-based signatures!