Skip to content

feat: add OCI registry signing and verification support#35

Open
SequeI wants to merge 3 commits intomainfrom
quaySign
Open

feat: add OCI registry signing and verification support#35
SequeI wants to merge 3 commits intomainfrom
quaySign

Conversation

@SequeI
Copy link
Member

@SequeI SequeI commented Jan 23, 2026

Summary

Add ability to sign and verify OCI container images directly in registries
without requiring model files on disk.

New features:

  • Sign images directly: model_signing sign sigstore quay.io/user/model:latest
  • Verify images: model_signing verify sigstore quay.io/user/model:latest
  • Smart target detection auto-identifies image refs vs local paths (--type to override)
  • Signature attachment via OCI 1.1 Referrers API or tag-based (--attachment-mode)
  • Verify local files match signed image with --local-model option
  • New Python API: sign_image() and verify_image() methods

Registry auth uses existing Docker/Podman credentials from config.json/auth.json.

New modules:

  • src/model_signing/_oci/registry.py - OCI registry interaction
  • src/model_signing/_oci/attachment.py - signature attachment handling

Depends on: oras>=0.2.30

Checklist
  • All commits are signed-off, using DCO
  • All new code has docstrings and type annotations
  • All new code is covered by tests. Aim for at least 90% coverage. CI is configured to highlight lines not covered by tests.
  • Public facing changes are paired with documentation changes
  • Release note has been added to CHANGELOG.md if needed

@qodo-code-review
Copy link

qodo-code-review bot commented Jan 23, 2026

ⓘ Your approaching your monthly quota for Qodo. Upgrade your plan

PR Compliance Guide 🔍

Below is a summary of compliance checks for this PR:

Security Compliance
Path traversal read

Description: Potential path traversal/local file read: _verify_oci_files_by_path() builds
local_file_path = model_path.joinpath(*path_parts) from manifest-provided paths (after
only normalizing </code> to /), so paths containing .. or absolute-like segments could escape
model_path and cause unintended local file reads during verification.
verifying.py [236-245]

Referred Code
normalized_digests = {
    p.replace("\\", "/"): d for p, d in expected_file_digests.items()
}

for file_path_str, expected_digest in normalized_digests.items():
    path_parts = pathlib.PurePosixPath(file_path_str).parts
    local_file_path = model_path.joinpath(*path_parts)

    if not local_file_path.exists():
        missing_files.append(file_path_str)
SSRF via registry URL

Description: Potential SSRF: _base_url() constructs a network base URL directly from the
user-controlled ImageReference.registry, enabling arbitrary outbound HTTP(S) requests to
attacker-chosen hosts if this library is used in a service context that verifies/signs
image refs supplied by untrusted users.
registry.py [149-155]

Referred Code
def _base_url(self, image_ref: ImageReference) -> str:
    """Get the base URL for a registry."""
    registry = image_ref.registry
    if registry in ("docker.io", "index.docker.io"):
        registry = "registry-1.docker.io"
    return f"{'http' if self._insecure else 'https'}://{registry}"
Ticket Compliance
🎫 No ticket provided
  • Create ticket/issue
Codebase Duplication Compliance
Codebase context is not defined

Follow the guide to enable codebase context checks.

Custom Compliance
🟢
Generic: Meaningful Naming and Self-Documenting Code

Objective: Ensure all identifiers clearly express their purpose and intent, making code
self-documenting

Status: Passed

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Security-First Input Validation and Data Handling

Objective: Ensure all data inputs are validated, sanitized, and handled securely to prevent
vulnerabilities

Status: Passed

Learn more about managing compliance generic rules or creating your own custom rules

🔴
Generic: Robust Error Handling and Edge Case Management

Objective: Ensure comprehensive error handling that provides meaningful context and graceful
degradation

Status: 🏷️
Swallowed exceptions: New registry signature fetch code catches broad exceptions and returns None, silently
hiding underlying failures and making troubleshooting and reliable verification behavior
difficult.

Referred Code
def _fetch_layer(
    self, client: OrasClient, image_ref: ImageReference, sig_digest: str
) -> bytes | None:
    """Fetch first layer blob from a signature artifact manifest."""
    try:
        manifest, _ = client.get_manifest(image_ref.with_digest(sig_digest))
        layers = manifest.get("layers", [])
        if not layers or not layers[0].get("digest"):
            return None
        return client.pull_blob(image_ref, layers[0]["digest"])
    except Exception:
        return None

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Comprehensive Audit Trails

Objective: To create a detailed and reliable record of critical system actions for security analysis
and compliance.

Status: 🏷️
Incomplete audit context: New signing/verification flows use tracing spans but do not clearly record a stable user
identifier and outcome in a durable audit log suitable for reconstructing critical
actions.

Referred Code
with tracer.start_as_current_span("Sign") as span:
    span.set_attribute("sigstore.sign_method", "sigstore")
    span.set_attribute("sigstore.target_type", detected_type.value)
    span.set_attribute(
        "sigstore.use_ambient_credentials", use_ambient_credentials
    )
    span.set_attribute("sigstore.use_staging", use_staging)

    try:
        config = model_signing.signing.Config().use_sigstore_signer(
            use_ambient_credentials=use_ambient_credentials,
            use_staging=use_staging,
            identity_token=identity_token,
            force_oob=oauth_force_oob,
            client_id=client_id,
            client_secret=client_secret,
            trust_config=trust_config,
        )

        if detected_type == TargetType.IMAGE:
            span.set_attribute("sigstore.image_ref", target)


 ... (clipped 6 lines)

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Secure Error Handling

Objective: To prevent the leakage of sensitive system information through error messages while
providing sufficient detail for internal debugging.

Status: 🏷️
Detailed CLI errors: Verification and signing failures are echoed directly to end users with the raw exception
message, which may reveal internal details depending on upstream exceptions.

Referred Code
except Exception as err:
    click.echo(f"Verification failed:\n{err}", err=True)
    sys.exit(1)

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Secure Logging Practices

Objective: To ensure logs are useful for debugging and auditing without exposing sensitive
information like PII, PHI, or cardholder data.

Status: 🏷️
Potential PII in traces: New tracing attributes include values like identity, image_ref, and local filesystem paths
which may be sensitive depending on deployment and tracing export configuration.

Referred Code
with tracer.start_as_current_span("Verify") as span:
    span.set_attribute("sigstore.method", "sigstore")
    span.set_attribute("sigstore.target_type", detected_type.value)
    span.set_attribute("sigstore.identity", identity)
    span.set_attribute("sigstore.oidc_issuer", identity_provider)
    span.set_attribute("sigstore.use_staging", use_staging)

    try:
        config = model_signing.verifying.Config().use_sigstore_verifier(
            identity=identity,
            oidc_issuer=identity_provider,
            use_staging=use_staging,
            trust_config=trust_config,
        )

        if detected_type == TargetType.IMAGE:
            span.set_attribute("sigstore.image_ref", target)
            if local_model:
                span.set_attribute("sigstore.local_model", str(local_model))
            # For images, attachment_mode=None means try both
            use_default = attachment_mode == "referrers"


 ... (clipped 13 lines)

Learn more about managing compliance generic rules or creating your own custom rules

  • Update
Compliance status legend 🟢 - Fully Compliant
🟡 - Partial Compliant
🔴 - Not Compliant
⚪ - Requires Further Human Verification
🏷️ - Compliance label

@SequeI SequeI changed the title feat: Add support for signing and verifying OCI image manifests feat: add OCI registry signing and verification support Jan 23, 2026
@qodo-code-review
Copy link

ⓘ Your approaching your monthly quota for Qodo. Upgrade your plan

PR Code Suggestions ✨

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
High-level
Inconsistently implement OCI image support

The new OCI image signing and verification feature is not consistently applied
across all CLI methods. It should be implemented for all methods, such as
pkcs11-key and certificate, or the limitation should be clearly documented.

Examples:

src/model_signing/_cli.py [399-521]
@_target_argument
@_type_option
@_ignore_paths_option
@_ignore_git_paths_option
@_allow_symlinks_option
@_write_signature_option
@_attachment_mode_option
@_sigstore_staging_option
@_trust_config_option
@click.option(

 ... (clipped 113 lines)
src/model_signing/_cli.py [611-648]
@_model_path_argument
@_ignore_paths_option
@_ignore_git_paths_option
@_allow_symlinks_option
@_write_signature_option
@_pkcs11_uri_option
def _sign_pkcs11_key(
    model_path: pathlib.Path,
    ignore_paths: Iterable[pathlib.Path],
    ignore_git_paths: bool,

 ... (clipped 28 lines)

Solution Walkthrough:

Before:

# src/model_signing/_cli.py

@_sign.command(name="sigstore")
@_target_argument
@_type_option
def _sign_sigstore(target: str, target_type: str, ...):
    detected_type = _detect_target_type(target, target_type)
    if detected_type == TargetType.IMAGE:
        # OCI image signing is implemented
        config.sign_image(target, ...)
    else:
        config.sign(pathlib.Path(target), ...)

@_sign.command(name="pkcs11-key")
@_model_path_argument
def _sign_pkcs11_key(model_path: pathlib.Path, ...):
    # OCI image signing is NOT implemented.
    # The command only accepts a local file path.
    model_signing.signing.Config().use_pkcs11_signer(...).sign(model_path, ...)

After:

# src/model_signing/_cli.py

@_sign.command(name="sigstore")
@_target_argument
@_type_option
def _sign_sigstore(target: str, target_type: str, ...):
    detected_type = _detect_target_type(target, target_type)
    if detected_type == TargetType.IMAGE:
        config.sign_image(target, ...)
    else:
        config.sign(pathlib.Path(target), ...)

@_sign.command(name="pkcs11-key")
@_target_argument
@_type_option
def _sign_pkcs11_key(target: str, target_type: str, ...):
    # OCI image signing is now consistently implemented.
    detected_type = _detect_target_type(target, target_type)
    if detected_type == TargetType.IMAGE:
        config.sign_image(target, ...)
    else:
        config.sign(pathlib.Path(target), ...)
Suggestion importance[1-10]: 9

__

Why: This suggestion correctly identifies a major inconsistency where the new OCI image support, the core feature of this PR, is only implemented for sigstore and key methods, leaving other CLI commands behind, which significantly impacts the feature's completeness and user experience.

High
General
Handle signature decoding errors gracefully

Handle potential UnicodeDecodeError and json.JSONDecodeError when decoding and
parsing the signature bytes by wrapping the operation in a try-except block.

src/model_signing/verifying.py [348-369]

 if self._uses_sigstore:
     from sigstore import models as sigstore_models
 
-    bundle = sigstore_models.Bundle.from_json(
-        signature_bytes.decode("utf-8")
-    )
+    try:
+        bundle = sigstore_models.Bundle.from_json(
+            signature_bytes.decode("utf-8")
+        )
+    except (UnicodeDecodeError, json.JSONDecodeError) as e:
+        raise ValueError(f"Invalid signature format: {e}") from e
     signature = sigstore.Signature(bundle)
 else:
     from sigstore_models.bundle import v1 as bundle_pb
 
-    parsed_dict = json.loads(signature_bytes.decode("utf-8"))
-    # Handle legacy bundle format - same as sigstore_pb.Signature.read
-    if "tlogEntries" not in parsed_dict.get("verificationMaterial", {}):
-        parsed_dict.setdefault("verificationMaterial", {})
-        parsed_dict["verificationMaterial"]["tlogEntries"] = []
+    try:
+        parsed_dict = json.loads(signature_bytes.decode("utf-8"))
+    except (UnicodeDecodeError, json.JSONDecodeError) as e:
+        raise ValueError(f"Invalid signature format: {e}") from e
     ...

[To ensure code accuracy, apply this suggestion manually]

Suggestion importance[1-10]: 7

__

Why: The suggestion correctly points out that decoding the signature bytes can fail if the data is corrupted or not valid UTF-8. Adding a try-except block to handle UnicodeDecodeError and json.JSONDecodeError makes the signature parsing more robust.

Medium
Filter tag fetch by signature type

In TagAttachment.fetch, implement filtering based on signature_type to ensure
the correct type of signature is returned, similar to the logic in
ReferrersAttachment.

src/model_signing/_oci/attachment.py [112-144]

 class TagAttachment:
     def fetch(
         self,
         client: OrasClient,
         image_ref: ImageReference,
         image_digest: str,
-        signature_type: str | None = None,  # noqa: ARG002
+        signature_type: str | None = None,
     ) -> bytes | None:
         sig_tag = self._digest_to_tag(image_digest)
         try:
-            manifest, _ = client.get_manifest(image_ref.with_tag(sig_tag))
+            sig_ref = image_ref.with_tag(sig_tag)
+            manifest, _ = client.get_manifest(sig_ref)
             layers = manifest.get("layers", [])
             if not layers or not layers[0].get("digest"):
                 return None
-            return client.pull_blob(image_ref, layers[0]["digest"])
+            blob = client.pull_blob(sig_ref, layers[0]["digest"])
+            if signature_type is None:
+                return blob
+            # reuse ReferrersAttachment matching logic
+            return ReferrersAttachment()._matches_type(blob, signature_type) and blob
         except Exception:
             return None
  • Apply / Chat
Suggestion importance[1-10]: 7

__

Why: The suggestion correctly identifies that the fetch method for TagAttachment ignores the signature_type, which could lead to incorrect signature verification if multiple signatures of different types exist. Implementing type checking, as is done for ReferrersAttachment, makes the logic more robust and correct.

Medium
Possible issue
Catch specific exceptions only

Replace the broad except Exception in _fetch_layer with more specific exceptions
like requests.HTTPError and ValueError to avoid catching system-exiting errors
and improve debugging.

src/model_signing/_oci/attachment.py [84-95]

 def _fetch_layer(
     self, client: OrasClient, image_ref: ImageReference, sig_digest: str
 ) -> bytes | None:
     """Fetch first layer blob from a signature artifact manifest."""
     try:
         manifest, _ = client.get_manifest(image_ref.with_digest(sig_digest))
         layers = manifest.get("layers", [])
         if not layers or not layers[0].get("digest"):
             return None
         return client.pull_blob(image_ref, layers[0]["digest"])
-    except Exception:
+    except (requests.HTTPError, ValueError, KeyError):
         return None
  • Apply / Chat
Suggestion importance[1-10]: 6

__

Why: The suggestion correctly identifies that using a broad except Exception is poor practice as it can mask critical errors. Replacing it with more specific exceptions improves the code's robustness and debuggability.

Low
  • More

@SequeI SequeI force-pushed the quaySign branch 3 times, most recently from 7041699 to f79b9e6 Compare January 27, 2026 11:58
@SequeI
Copy link
Member Author

SequeI commented Jan 27, 2026

/review

@qodo-code-review
Copy link

ⓘ Your monthly quota for Qodo has expired. Upgrade your plan
ⓘ Paying users. Check that your Qodo account is linked with this Git user account

@SequeI SequeI force-pushed the quaySign branch 6 times, most recently from 3f7a493 to ec2fe48 Compare January 27, 2026 15:44
Add ability to sign and verify OCI container images directly in registries
without requiring model files on disk.

New features:
- Sign images directly: `model_signing sign sigstore quay.io/user/model:latest`
- Verify images: `model_signing verify sigstore quay.io/user/model:latest`
- Signature attachment via OCI 1.1 Referrers API or tag-based (--attachment-mode)
- Verify local files match signed image with --local-model option
- New Python API: sign_image() and verify_image() methods

Registry auth uses existing Docker/Podman credentials from config.json/auth.json.

Depends on: oras>=0.2.30

Signed-off-by: SequeI <asiek@redhat.com>
Signed-off-by: SequeI <asiek@redhat.com>
Copy link

@jonburdo jonburdo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as I can tell image signing will work with a small change

Signed-off-by: SequeI <asiek@redhat.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants