Series: Understanding OCI from the Ground Up (Part 4 of 5)

In Part 1 we built an OCI image. In Part 2 we pushed and pulled it with raw HTTP. In Part 3 we ran it without a runtime. Now we sign it — and discover that a signature is just another OCI manifest with a `subject` field, stored alongside the image via the same Distribution API we already understand.

What is Notation?

The Notary Project is a CNCF specification for signing and verifying OCI artifacts. Notation is the reference CLI that implements it.

TermWhat it is
Notary ProjectThe spec that defines signature envelopes, trust policies, signing schemes
Notation CLIThe command-line tool we'll use (notation sign, notation verify)
COSE / JWSThe envelope formats Notation supports for the signature itself

In short: Notation cryptographically signs a container image so anyone pulling it can prove it came from a trusted party and hasn't been tampered with.


Why Sign Images?

Without signatures there's no guarantee that the bytes we pull from a registry are:

  • Authentic — actually built by the expected source (our CI, our team)
  • Intact — not modified after being built (no supply-chain tampering)

Signatures solve both. A Kubernetes admission controller (Kyverno, Ratify) can refuse to start a pod whose image has no valid signature. A CI pipeline can verify upstream base images before building on them.


How Notation Signing Works — The Conceptual Flow

┌──────────────┐       ┌────────────────────┐       ┌──────────────────────┐
│  Build Image │──────▶│  Push to Registry  │──────▶│  notation sign       │
│              │       │                    │       │  - Computes digest   │
└──────────────┘       └────────────────────┘       │  - Creates COSE env  │
                                                      │  - Signs with key    │
                                                      │  - Pushes signature  │
                                                      │    as OCI artifact   │
                                                      │    pointing to image │
                                                      │    via `subject`     │
                                                      └──────────┬───────────┘
                                                                 │
                                                                 ▼
                                                      ┌──────────────────────┐
                                                      │  notation verify     │
                                                      │  - Discovers via     │
                                                      │    Referrers API     │
                                                      │  - Checks signature  │
                                                      │  - Checks trust      │
                                                      │    policy            │
                                                      └──────────────────────┘

Three things to internalize before we go to the lab:

  1. The signature lives in the same registry as the image — as a separate OCI artifact, not embedded in the image.
  2. The image is never modified — its manifest digest is identical before and after signing. Digest pinning still works.
  3. The link from signature to image is the OCI 1.1 subject field — exactly the same mechanism we'll use for SBOMs in Part 5.

Key Concepts

Signature Envelope

Notation wraps the signature in either COSE (CBOR Object Signing and Encryption — binary) or JWS (JSON Web Signature — text). COSE is the default. The envelope contains:

  • The signed payload (the image's manifest digest, mediaType, size)
  • The signing certificate chain
  • The cryptographic signature itself
  • Signed/unsigned attributes (timestamp, signing scheme, signing agent)

Trust Store

A directory of certificates that Notation considers trusted. We add our signing cert here so notation verify recognizes it.

Trust Policy

A JSON document (trustpolicy.json) that maps registry scopes to trust stores and verification levels:

{
  "version": "1.0",
  "trustPolicies": [
    {
      "name": "lab-policy",
      "registryScopes": ["*"],
      "signatureVerification": { "level": "strict" },
      "trustStores": ["ca:lab-test"],
      "trustedIdentities": ["*"]
    }
  ]
}

level values: strict (all checks must pass), permissive (warn on minor issues), audit (log only), skip (no verification).

Signing Key

A private key paired with an X.509 certificate. The cert must declare keyUsage: digitalSignature and extendedKeyUsage: codeSigning. For real production use, the private key would live in an HSM or KMS; for the lab we'll generate one with OpenSSL.


Prerequisites — The Lab

Same network-of-containers pattern as Parts 2 and 3:

docker network create oci-net
docker run -d --name oci-registry --network oci-net -p 5000:5000 registry:2
docker run -d --name oci-lab --network oci-net ubuntu:22.04 sleep 7200

docker exec oci-lab bash -c \
  'apt-get update -qq && apt-get install -y -qq curl jq openssl skopeo > /dev/null 2>&1'

Install Notation CLI

docker exec oci-lab bash -c '
  NOTATION_VERSION="1.3.0"
  curl -sSLo /tmp/notation.tar.gz \
    "https://github.com/notaryproject/notation/releases/download/v${NOTATION_VERSION}/notation_${NOTATION_VERSION}_linux_arm64.tar.gz"
  tar -xzf /tmp/notation.tar.gz -C /tmp/
  mv /tmp/notation /usr/local/bin/
  notation version
'

Use linux_amd64 instead of linux_arm64 on Intel.

Notation - a tool to sign and verify artifacts.
Version:     1.3.0
Go version:  go1.23.5
Git commit:  8e5b5d59f5f5b45db20277614d5cfd73d4845c3a

Push the target image

docker exec oci-lab skopeo copy --dest-tls-verify=false \
  docker://ubuntu:22.04 \
  docker://oci-registry:5000/ubuntu-curl:v1

Capture the manifest digest — we'll sign by digest, not tag:

docker exec oci-lab bash -c '
  curl -sI http://oci-registry:5000/v2/ubuntu-curl/manifests/v1 \
    -H "Accept: application/vnd.oci.image.manifest.v1+json" \
    | grep -i docker-content-digest
'
Docker-Content-Digest: sha256:0124b5388c7c05576c0cedeab121a7c590b0ec16b4238e6b997ad9d57ccdefd8

Step 1: The "Before" Picture

Right after pushing, the registry contains exactly these objects:

Tags:
  v1  →  sha256:0124b538...

Blobs:
  sha256:0124b538...  →  image manifest (424 B)
  sha256:8bdde1d7...  →  image config (2,069 B)
  sha256:6edbc812...  →  image layer (27,606,543 B)

The image manifest has no subject field, no referrers — just a standard OCI image:

{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.manifest.v1+json",
  "config": {
    "mediaType": "application/vnd.oci.image.config.v1+json",
    "size": 2069,
    "digest": "sha256:8bdde1d7..."
  },
  "layers": [
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "size": 27606543,
      "digest": "sha256:6edbc812..."
    }
  ]
}

And the Referrers API returns 404 (because registry:2 doesn't implement it yet — more on this below):

docker exec oci-lab bash -c '
  curl -s http://oci-registry:5000/v2/ubuntu-curl/referrers/sha256:0124b5388c7c05576c0cedeab121a7c590b0ec16b4238e6b997ad9d57ccdefd8
'
404 page not found

Step 2: Generate a Self-Signed Signing Key

docker exec -w /work oci-lab bash -c '
  mkdir -p /work && cd /work
  openssl genrsa -out notation-signing.key 3072 2>/dev/null

  cat < notation-cert.cnf
[ req ]
default_bits       = 3072
prompt             = no
distinguished_name = req_dn
x509_extensions    = v3_ext

[ req_dn ]
C  = IN
ST = Telangana
L  = Hyderabad
O  = OCI-Lab
CN = notation-signing

[ v3_ext ]
keyUsage         = critical, digitalSignature
extendedKeyUsage = codeSigning
basicConstraints = critical, CA:FALSE
subjectKeyIdentifier = hash
CONF

  openssl req -x509 -new -nodes \
    -key notation-signing.key -sha256 -days 365 \
    -out notation-signing.crt -config notation-cert.cnf

  openssl x509 -in notation-signing.crt -text -noout | grep -A2 "Key Usage"
'
X509v3 Key Usage: critical
    Digital Signature
X509v3 Extended Key Usage:
    Code Signing
X509v3 Basic Constraints: critical

The two extended-usage flags above are mandatory: Notation refuses to use a cert that's missing digitalSignature + codeSigning.


Step 3: Register the Key with Notation

The Notation CLI does not have a flag to pass key/cert paths directly. notation sign --key expects a key name, not a file path. Local file-based keys are registered via signingkeys.json:

docker exec oci-lab bash -c '
  mkdir -p "${HOME}/.config/notation"

  cat < "${HOME}/.config/notation/signingkeys.json"
{
  "default": "lab-key",
  "keys": [
    {
      "name": "lab-key",
      "keyPath": "/work/notation-signing.key",
      "certPath": "/work/notation-signing.crt"
    }
  ]
}
EOF

  notation key ls
'
NAME        KEY PATH                      CERTIFICATE PATH
* lab-key   /work/notation-signing.key    /work/notation-signing.crt

The notation key add command only registers plugin-based keys (KMS/HSM plugins). For local files, signingkeys.json is the only mechanism.


Step 4: Sign the Image

docker exec oci-lab bash -c '
  MANIFEST_DIGEST="sha256:0124b5388c7c05576c0cedeab121a7c590b0ec16b4238e6b997ad9d57ccdefd8"
  IMAGE_REF="oci-registry:5000/ubuntu-curl@${MANIFEST_DIGEST}"
  notation sign "$IMAGE_REF" --key lab-key --signature-format cose --insecure-registry
'
Successfully signed oci-registry:5000/ubuntu-curl@sha256:0124b5388c7c05576c0cedeab121a7c590b0ec16b4238e6b997ad9d57ccdefd8

That single command did three pushes against the registry. Let's see what changed.


Step 5: The "After" Picture — Three New Objects

The tag list changed from one tag to two:

docker exec oci-lab bash -c '
  curl -s http://oci-registry:5000/v2/ubuntu-curl/tags/list | jq .
'
{
  "name": "ubuntu-curl",
  "tags": [
    "v1",
    "sha256-0124b5388c7c05576c0cedeab121a7c590b0ec16b4238e6b997ad9d57ccdefd8"
  ]
}

A new tag appeared: sha256-0124b5388c.... Three new blobs were pushed. Let's dissect each.


Object 1: The Signature Manifest — the Heart of It

docker exec oci-lab bash -c '
  curl -s http://oci-registry:5000/v2/ubuntu-curl/manifests/sha256:3f617f355a2407de082a54420344ba6846cbde550e345e28ae74ae6d0e0c5d04 \
    -H "Accept: application/vnd.oci.image.manifest.v1+json" | jq .
'
{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.manifest.v1+json",
  "config": {
    "mediaType": "application/vnd.cncf.notary.signature",
    "digest": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
    "size": 2
  },
  "layers": [
    {
      "mediaType": "application/cose",
      "digest": "sha256:cb18797c783d8723314f7177d1a48e0ecf8171e4bfc2c614d1febb1b4f68e777",
      "size": 1970
    }
  ],
  "subject": {
    "mediaType": "application/vnd.oci.image.manifest.v1+json",
    "digest": "sha256:0124b5388c7c05576c0cedeab121a7c590b0ec16b4238e6b997ad9d57ccdefd8",
    "size": 424
  },
  "annotations": {
    "io.cncf.notary.x509chain.thumbprint#S256": "[\"aea4fd75903a195dccfe46f99afda7d72e84cb20cba925306cf9f528bc3a56f8\"]",
    "org.opencontainers.image.created": "2026-05-01T09:20:29Z"
  }
}

This is a perfectly ordinary OCI image manifest. Notation didn't invent a new manifest format — it reused the existing one, with three meaningful differences:

FieldNormal Image ManifestSignature Manifest
config.mediaTypeapplication/vnd.oci.image.config.v1+jsonapplication/vnd.cncf.notary.signature
layers[0].mediaTypeapplication/vnd.oci.image.layer.v1.tar+gzipapplication/cose
subjectabsentpoints to the signed image's digest

The subject Field — the OCI 1.1 Link

"subject": {
  "mediaType": "application/vnd.oci.image.manifest.v1+json",
  "digest": "sha256:0124b538...",
  "size": 424
}

This is the OCI 1.1 subject descriptor. It tells the registry: "This manifest is about sha256:0124b538...." It's a standard OCI descriptor (mediaType + digest + size) pointing to our original image manifest.

The original image manifest is untouched. Its digest doesn't change. The signature points to the image, not the other way around. Anyone pinning ubuntu-curl@sha256:0124b538... gets the same bytes before and after signing.

The config — Empty JSON

docker exec oci-lab bash -c '
  curl -s http://oci-registry:5000/v2/ubuntu-curl/blobs/sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a
'
{}

The config blob is just {} (2 bytes). For signature artifacts, all the meaningful data is in the layers. The config's mediaType (application/vnd.cncf.notary.signature) is what identifies this manifest as a Notation signature; the config content itself is unused.

The Layer — the COSE Envelope

docker exec oci-lab bash -c '
  curl -s http://oci-registry:5000/v2/ubuntu-curl/blobs/sha256:cb18797c783d8723314f7177d1a48e0ecf8171e4bfc2c614d1febb1b4f68e777 | base64 | head -5
'
0oRYnqUBOCUCgXgcaW8uY25jZi5ub3Rhcnkuc2lnbmluZ1NjaGVtZQN4K2FwcGxpY2F0
aW9uL3ZuZC5jbmNmLm5vdGFyeS5wYXlsb2FkLnYxK2pzb254GmlvLmNuY2Yubm90YXJ5
LnNpZ25pbmdUaW1lwRpp9HBdeBxpby5jbmNmLm5vdGFyeS5zaWduaW5nU2NoZW1la25v
dGFyeS54NTA5ohghgVkEqjCCBKYwggMOoAMCAQICFBNEitN4s3xJmNrPy2wdTqP3L0JQ
MA0GCSqGSIb3DQEBCwUAMGIxCzAJBgNVBAYTAklOMRIwEAYDVQQIDAlUZWxhbmdhbmEx

That's binary CBOR (CBOR Object Signing and Encryption). Decoded, the COSE Sign1 envelope contains:

COSE Sign1 Envelope (1,970 bytes)
├── Protected Headers
│   ├── Algorithm: RSASSA-PSS-SHA-384
│   ├── Content Type: application/vnd.cncf.notary.payload.v1+json
│   ├── Signing Scheme: notary.x509
│   └── Signing Time: 2026-05-01T09:20:29Z
├── Payload (the signed claims)
│   ├── targetArtifact.digest: "sha256:0124b538..."
│   ├── targetArtifact.mediaType: "application/vnd.oci.image.manifest.v1+json"
│   └── targetArtifact.size: 424
├── Certificate Chain
│   └── notation-signing.crt (self-signed, CN=notation-signing, O=OCI-Lab)
└── Signature Bytes
    └── RSA-PSS signature over (protected headers + payload)

The payload is not the image content. It's a tiny JSON document containing the manifest's digest, mediaType, and size — the same three fields as a descriptor. The signature proves: "The holder of this private key certifies that the artifact at sha256:0124b538... is authentic." Anyone with the public cert can verify the claim; the registry doesn't have to be trusted.


Object 2: The Referrer Index — How Discovery Works

docker exec oci-lab bash -c '
  curl -s "http://oci-registry:5000/v2/ubuntu-curl/manifests/sha256-0124b5388c7c05576c0cedeab121a7c590b0ec16b4238e6b997ad9d57ccdefd8" \
    -H "Accept: application/vnd.oci.image.index.v1+json" | jq .
'
{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.index.v1+json",
  "manifests": [
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "digest": "sha256:3f617f355a2407de082a54420344ba6846cbde550e345e28ae74ae6d0e0c5d04",
      "size": 723,
      "annotations": {
        "io.cncf.notary.x509chain.thumbprint#S256": "[\"aea4fd75903a195dccfe46f99afda7d72e84cb20cba925306cf9f528bc3a56f8\"]",
        "org.opencontainers.image.created": "2026-05-01T09:20:29Z"
      },
      "artifactType": "application/vnd.cncf.notary.signature"
    }
  ]
}

This is an OCI Image Index — the same structure used for multi-architecture images. Instead of listing platform-specific manifests, it lists every artifact that references our image.

The tag name is the discovery mechanism. To find all referrers for digest sha256:0124b538..., clients look for a tag named sha256-0124b538... (the digest with : replaced by -). This is the tag-based referrers fallback defined in OCI Distribution 1.1.


The Referrers API vs the Tag Fallback

The OCI 1.1 Distribution Spec defines two ways to discover referrers.

Method A — The Referrers API (preferred)

GET /v2/<name>/referrers/<digest>
→ 200 OK
→ Content-Type: application/vnd.oci.image.index.v1+json
→ Body: { "manifests": [ ... ] }

The registry dynamically computes the index of every manifest whose subject points to <digest>. Supported by Zot, Harbor 2.9+, GHCR, ECR, ACR, recent Docker Hub.

You can filter:

GET /v2/<name>/referrers/<digest>?artifactType=application/vnd.cncf.notary.signature
→ OCI-Filters-Applied: artifactType

Method B — Tag-Based Fallback (for older registries)

GET /v2/<name>/manifests/sha256-<digest>
→ 200 OK
→ Body: { "manifests": [ ... ] }

When the Referrers API returns 404, clients fall back to looking up a tag named sha256-<hex>. The client (notation, oras, trivy) is responsible for maintaining the index — when pushing a new referrer:

  1. Push the new artifact's blobs and manifest
  2. Fetch the existing referrer index tag (or start a new one)
  3. Append the new referrer descriptor
  4. Push the updated index back to the same tag

This is exactly what Notation did in our lab. registry:2 returned 404 for the Referrers API, so:

registry:2 does not support Referrers API:

  GET /v2/ubuntu-curl/referrers/sha256:0124b538...
  → 404 Not Found

  Notation falls back to tag-based:
  GET /v2/ubuntu-curl/manifests/sha256-0124b538...
  → 404 (first time) → create new index
  → 200 (subsequent) → append to existing index

Multiple Signatures Stack in the Same Index

Sign the same image again:

docker exec oci-lab bash -c '
  notation sign oci-registry:5000/ubuntu-curl@sha256:0124b5388c7c05576c0cedeab121a7c590b0ec16b4238e6b997ad9d57ccdefd8 \
    --key lab-key --signature-format cose --insecure-registry
'

The referrer index now lists both signatures:

{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.index.v1+json",
  "manifests": [
    {
      "digest": "sha256:3f617f35...",
      "artifactType": "application/vnd.cncf.notary.signature",
      "annotations": {
        "org.opencontainers.image.created": "2026-05-01T09:20:29Z"
      }
    },
    {
      "digest": "sha256:66bfeb4d...",
      "artifactType": "application/vnd.cncf.notary.signature",
      "annotations": {
        "org.opencontainers.image.created": "2026-05-01T09:29:33Z"
      }
    }
  ]
}

The tag doesn't change — its content does. The same sha256-0124b538... tag now points to a new index manifest listing two signature manifests. Each has its own COSE blob and points to the same image via subject.


Step 6: Inspect the Signature with notation inspect

docker exec oci-lab bash -c '
  notation inspect oci-registry:5000/ubuntu-curl@sha256:0124b5388c7c05576c0cedeab121a7c590b0ec16b4238e6b997ad9d57ccdefd8 \
    --insecure-registry
'
Inspecting all signatures for signed artifact
oci-registry:5000/ubuntu-curl@sha256:0124b5388c7c05576c0cedeab121a7c590b0ec16b4238e6b997ad9d57ccdefd8
└── application/vnd.cncf.notary.signature                          ← artifactType
    └── sha256:3f617f35...                                         ← signature manifest digest
        ├── media type: application/cose                           ← envelope format
        ├── signature algorithm: RSASSA-PSS-SHA-384                ← signing algorithm
        ├── signed attributes
        │   ├── signingScheme: notary.x509                         ← X.509 cert-based
        │   └── signingTime: Fri May  1 09:20:29 2026
        ├── unsigned attributes
        │   └── signingAgent: notation-go/1.3.0
        ├── certificates
        │   └── SHA256 fingerprint: aea4fd75...
        │       ├── issued to: CN=notation-signing,O=OCI-Lab,...
        │       ├── issued by: CN=notation-signing,O=OCI-Lab,...   ← same = self-signed
        │       └── expiry: Sat May  1 09:20:16 2027
        └── signed artifact
            ├── media type: application/vnd.oci.image.manifest.v1+json
            ├── digest: sha256:0124b538...                        ← what was signed
            └── size: 424

Every line on that tree corresponds to a field in the COSE envelope or the signature manifest we already saw above. There's no magic.


Step 7: Verify the Signature

Add the certificate to a trust store, write a trust policy, and verify:

docker exec oci-lab bash -c '
  notation cert add --type ca --store lab-test /work/notation-signing.crt

  cat < "${HOME}/.config/notation/trustpolicy.json"
{
  "version": "1.0",
  "trustPolicies": [
    {
      "name": "lab-policy",
      "registryScopes": ["*"],
      "signatureVerification": { "level": "strict" },
      "trustStores": ["ca:lab-test"],
      "trustedIdentities": ["*"]
    }
  ]
}
EOF

  notation verify \
    oci-registry:5000/ubuntu-curl@sha256:0124b5388c7c05576c0cedeab121a7c590b0ec16b4238e6b997ad9d57ccdefd8 \
    --insecure-registry
'
Successfully verified signature for oci-registry:5000/ubuntu-curl@sha256:0124b5388c7c05576c0cedeab121a7c590b0ec16b4238e6b997ad9d57ccdefd8

What notation verify Actually Does — the HTTP Trace

1. Fetch the image manifest
   GET /v2/ubuntu-curl/manifests/sha256:0124b538...
   → 200 OK → image manifest (424 B)

2. Try the Referrers API first
   GET /v2/ubuntu-curl/referrers/sha256:0124b538...
   → 404 Not Found  (registry:2 doesn't support it)

3. Fall back to the tag-based lookup
   GET /v2/ubuntu-curl/manifests/sha256-0124b538...
   → 200 OK → referrer index listing signature manifests

4. For each signature manifest:
   GET /v2/ubuntu-curl/manifests/sha256:3f617f35...
   → 200 OK → signature manifest

5. Fetch the COSE signature blob
   GET /v2/ubuntu-curl/blobs/sha256:cb18797c...
   → 200 OK → COSE Sign1 envelope (1,970 B)

6. Verify locally:
   a. Parse the COSE envelope → extract payload + cert chain + signature
   b. Check payload.targetArtifact.digest matches the image manifest digest
   c. Check the cert chain against the trust store (ca:lab-test)
   d. Check cert validity (not expired, has codeSigning EKU)
   e. Verify the RSA-PSS signature using the cert's public key
   f. Apply the trust policy (registryScopes, level=strict)
   → All pass → "Successfully verified"

Every step is something a curl command and a few lines of Go could do. There's no proprietary protocol; it's just OCI Distribution + standard X.509/COSE crypto.


Object Map — Before and After

BEFORE SIGNING:
┌─────────────────────────────────────────────────────────────────┐
│  Tags:                                                          │
│    v1 ──► sha256:0124b538... (image manifest)                   │
│                                                                 │
│  Blobs:                                                         │
│    sha256:0124b538... = image manifest    (424 B)               │
│    sha256:8bdde1d7... = image config      (2,069 B)             │
│    sha256:6edbc812... = Ubuntu layer      (27,606,543 B)        │
└─────────────────────────────────────────────────────────────────┘

AFTER SIGNING:
┌─────────────────────────────────────────────────────────────────┐
│  Tags:                                                          │
│    v1 ──► sha256:0124b538... (image manifest, unchanged)        │
│    sha256-0124b538... ──► referrer index                        │
│                                                                 │
│  Original blobs (untouched):                                    │
│    sha256:0124b538... = image manifest    (424 B)               │
│    sha256:8bdde1d7... = image config      (2,069 B)             │
│    sha256:6edbc812... = Ubuntu layer      (27,606,543 B)        │
│                                                                 │
│  New blobs from notation sign:                                  │
│    sha256:3f617f35... = signature manifest (723 B)              │
│    sha256:44136fa3... = empty config {}    (2 B)                │
│    sha256:cb18797c... = COSE signature    (1,970 B)             │
│                                                                 │
│  Relationships:                                                 │
│    referrer index ──lists──► signature manifest                 │
│    signature manifest ──subject──► image manifest               │
│    signature manifest ──layers──► COSE blob                     │
└─────────────────────────────────────────────────────────────────┘

The Key Insight — Signatures Are Just OCI Manifests

The genius of the OCI 1.1 design is that signatures are not a special case. They are regular OCI manifests with:

  1. A subject field pointing to what they're about
  2. A config.mediaType (or artifactType) that identifies the kind of artifact
  3. Layers containing the actual data (here, a COSE envelope)

The registry doesn't need to understand signatures, SBOMs, or attestations. It stores manifests and blobs and serves the Referrers API. The semantics live in the media types and the subject relationship.

This is why our minimal registry:2 works — it stores blobs and manifests; it has no idea what a "signature" is. And it's why in Part 5 we'll use the exact same plumbing to attach SBOMs.


Cleanup

docker rm -f oci-registry oci-lab
docker network rm oci-net

Recap

In this part we:

StepToolResult
Generated a self-signed key + certopensslRSA-3072, codeSigning EKU
Registered the key with Notationsigningkeys.jsonlab-key ready to use
Signed an imagenotation sign3 new blobs + 1 new tag in registry
Inspected the signature manifestcurlOCI manifest with subject field
Inspected the COSE envelopecurl + base64Payload = image descriptor; signature = RSA-PSS
Inspected the referrer indexcurl (via sha256-<digest> tag)Image Index listing all signatures
Verified the signaturenotation verify"Successfully verified"

The big takeaway

A Notation signature is an OCI manifest with artifactType: application/vnd.cncf.notary.signature, a subject field pointing to the signed image, and a layer containing a COSE Sign1 envelope. Discovery uses either the Referrers API or its tag-based fallback. Verification is offline cryptography against a trust store and policy.

Once you understand subject + Referrers, you understand notation signatures, SBOMs, vulnerability reports, SLSA provenance attestations — the entire OCI supply-chain ecosystem.


Up Next

In Part 5, we use the exact same subject + Referrers mechanism to attach SBOMs (SPDX and CycloneDX) and vulnerability scan results to our image — proving the design generalizes far beyond signatures.


All digests, byte counts, tag names, and command outputs in this post were captured from an actual run inside Docker Desktop for Mac (arm64) on May 1, 2026. Tools used: stock registry:2, Notation CLI 1.3.0, OpenSSL with a self-signed RSA-3072 certificate.