Files
Evan Jarrett 6758996300 add SBOM package diffing, verify hold-service captain records
- diff view gains a Packages tab with added/removed/changed/unchanged
  package tables and purl-derived type/license/upstream links
- captain records verified against the DID's atcr_hold service before
  caching (processor + batch backfill), preventing forged holds
- fix empty-handle updates clobbering cached handles and colliding on
  the UNIQUE constraint
- move fillPrevCIDs into repo.go; DirectRepoOperator is now canonical,
  repomgr kept as a test oracle
- surface read-only crew status in hold selector
- reconcile docs
2026-06-13 12:49:03 -05:00

23 KiB

Image Signing with ATProto (Research / POC)

STATUS: RESEARCH / DESIGN PROPOSAL. Last reviewed 2026-06-11.

Nothing in this document is implemented. As of this review there is no signature artifact creation, no OCI Referrers API endpoint in the AppView, no cmd/atcr-verify binary, no pkg/verify package, no hold-as-CA code, no io.atcr.hold.coSignManifest / io.atcr.hold.reSignManifest XRPC endpoints, and no HOLD_COSIGN_* configuration. The CLI examples, install instructions, oras/cosign command transcripts, Kubernetes manifests, and version output shown below are illustrative of a proposed design, not working features.

This file consolidates what were previously six separate docs (IMAGE_SIGNING.md, ATPROTO_SIGNATURES.md, SIGNATURE_INTEGRATION.md, HOLD_AS_CA.md, ATCR_VERIFY_CLI.md, INTEGRATION_STRATEGY.md) into a single research note. Several of the originals presented features as shipped ("Available Now", install commands, version strings); those claims have been removed or relabeled as proposals. See "Corrections from review" at the end for the specific factual fixes applied during consolidation.

Table of contents

  1. What is real today
  2. Proposed design: ORAS signature artifacts + Referrers API
  3. Alternative proposal: Hold-as-CA (X.509)
  4. Proposed tool: atcr-verify CLI
  5. Integration notes (hypothetical examples)
  6. Trust model and security considerations
  7. Comparison with other signing solutions
  8. Proposed implementation roadmap
  9. Corrections from review

1. What is real today

This section describes mechanisms that genuinely exist in the protocol layer ATCR builds on. It is the accurate core extracted from the original ATPROTO_SIGNATURES.md.

Manifests are signed by the user's PDS

When a manifest is stored in ATCR, it lands in the user's PDS as an io.atcr.manifest record. ATProto's repository model means that record is part of a commit that the PDS signs:

  1. AppView stores the manifest as an io.atcr.manifest record in the user's PDS.
  2. The PDS creates a repository commit containing the new record in its Merkle Search Tree (MST).
  3. The PDS signs that commit with the repository's signing key (ECDSA over the secp256k1 / K-256 curve).
  4. The signature lives in the commit object and becomes part of the user's verifiable repository chain.

This is a real, existing property of ATProto repositories. It is not an ATCR feature that needed to be built; it is inherited from the PDS.

Signature algorithm

  • Curve: secp256k1 (K-256), the same curve used by Bitcoin/Ethereum.
  • Hash: SHA-256.
  • Signing: serialize commit data as DAG-CBOR, hash with SHA-256, sign with the K-256 private key. ATProto uses "low-S" signatures (per BIP-0062).

Note that ATProto repository keys are K-256. This is distinct from the P-256 (ES256) key the AppView uses for OAuth, and distinct from the P-256 curve that Notation expects (see the Hold-as-CA section for why that matters).

Public key distribution

Public keys are published in DID documents and obtained through DID resolution:

did:plc:alice123
       -> Query PLC directory: https://plc.directory/did:plc:alice123
       -> DID document verificationMethod (id "#atproto")
          publicKeyMultibase: zQ3sh... (multibase base58btc, multicodec 0xE701 for K-256)

For did:web identities the document is served from the web origin instead of the PLC directory.

What can be verified today, and how

Given an image and the manifest record behind it, a verifier can prove the manifest came from a specific DID and was not tampered with, using only existing ATProto endpoints:

  1. Resolve the image reference to a manifest digest.
  2. Find the corresponding io.atcr.manifest record in the user's PDS (com.atproto.repo.getRecord / listRecords).
  3. Fetch the repository (com.atproto.sync.getRepo) and extract the signed commit covering that record.
  4. Resolve the DID to its public key from the DID document.
  5. Verify the commit signature against the public key using a K-256 verifier (e.g. github.com/bluesky-social/indigo/atproto/crypto).

Sketch of the verification core (existing libraries, no new ATCR code):

import "github.com/bluesky-social/indigo/atproto/crypto"

// pubKey parsed from the DID document's publicKeyMultibase (K-256)
// commit parsed from the CAR returned by com.atproto.sync.getRepo
bytesToVerify := commit.Unsigned().BytesForSigning()
err := pubKey.Verify(bytesToVerify, commit.Sig)

This is the foundation every proposal below builds on. None of the bridging, discovery, or tooling that would make this convenient for OCI consumers exists yet.


2. Proposed design: ORAS signature artifacts + Referrers API

Proposal. Not implemented. There is no Referrers API endpoint in the AppView and no code that creates signature artifacts.

The OCI ecosystem does not understand ATProto records. The proposal here is to publish a small ORAS signature artifact that points at the existing ATProto signature, so that standard OCI tooling can at least discover it via the OCI Referrers API, and a custom verifier can then follow the pointer to the real ATProto signature.

Proposed artifact format

The signature artifact would be an OCI image manifest with a subject field referencing the image manifest, an artifactType of application/vnd.atproto.signature.v1+json, and a single layer carrying the metadata blob:

{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.manifest.v1+json",
  "artifactType": "application/vnd.atproto.signature.v1+json",
  "config": {
    "mediaType": "application/vnd.oci.empty.v1+json",
    "digest": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
    "size": 2
  },
  "subject": {
    "mediaType": "application/vnd.oci.image.manifest.v1+json",
    "digest": "sha256:<image-manifest-digest>",
    "size": 1234
  },
  "layers": [
    {
      "mediaType": "application/vnd.atproto.signature.v1+json",
      "digest": "sha256:<metadata-blob-digest>",
      "size": 512,
      "annotations": { "org.opencontainers.image.title": "atproto-signature.json" }
    }
  ],
  "annotations": {
    "io.atcr.atproto.did": "did:plc:alice123",
    "io.atcr.atproto.pds": "https://bsky.social",
    "io.atcr.atproto.recordUri": "at://did:plc:alice123/io.atcr.manifest/<rkey>",
    "io.atcr.atproto.commitCid": "bafyreih8...",
    "io.atcr.atproto.signedAt": "2025-10-31T12:34:56.789Z",
    "io.atcr.atproto.keyId": "did:plc:alice123#atproto"
  }
}

The metadata blob would carry enough to find and verify the underlying ATProto signature:

{
  "$type": "io.atcr.atproto.signature",
  "version": "1.0",
  "subject": { "digest": "sha256:<image-manifest-digest>", "mediaType": "application/vnd.oci.image.manifest.v1+json" },
  "atproto": {
    "did": "did:plc:alice123",
    "handle": "alice.bsky.social",
    "pdsEndpoint": "https://bsky.social",
    "recordUri": "at://did:plc:alice123/io.atcr.manifest/<rkey>",
    "recordCid": "bafyreig7...",
    "commitCid": "bafyreih8...",
    "commitRev": "3jzfkjqwdwa2a",
    "signedAt": "2025-10-31T12:34:56.789Z"
  },
  "signature": {
    "algorithm": "ECDSA-K256-SHA256",
    "keyId": "did:plc:alice123#atproto",
    "publicKeyMultibase": "zQ3shokFTS3brHcDQrn82RUDfCZESWL1ZdCEJwekUDdo1Ko4Z"
  },
  "verification": {
    "method": "atproto-repo-commit",
    "instructions": "Fetch repository commit from PDS and verify signature using public key from DID document"
  }
}

Proposed discovery via the Referrers API

If the AppView implemented the OCI Referrers API, discovery would look like:

GET /v2/<owner>/<repo>/referrers/sha256:<image-manifest-digest>
    ?artifactType=application/vnd.atproto.signature.v1+json

returning an OCI image index listing matching signature artifacts. This endpoint does not exist yet.

Why standard tools cannot verify these

Even with discovery in place, standard OCI tools (cosign verify, notation verify) could not verify an ATProto signature: they expect their own signature formats and trust models. They would only be able to list the artifact. Verification would require a custom tool (see the proposed atcr-verify CLI) or a plugin.

Proposed storage approach

The proposal is for the AppView, after storing a manifest, to read back the commit CID/revision, build the metadata blob, and create the ORAS artifact linked via subject. This is design intent only; the code path does not exist.

Correction (do not confuse with how SBOMs/scans work today): the original docs claimed signature artifacts would follow "the same pattern as SBOMs" and that SBOMs are stored as ORAS artifacts. That is wrong. SBOMs and scan results are stored as io.atcr.hold.scan ATProto records in the hold's CAR store (see pkg/hold/pds/scan.go, CreateScanRecord -> repomgr.UpsertRecord), with the SBOM carried as a blob reference inside the record. There is no ORAS/Referrers machinery behind scans. If signature artifacts were ever built, they would be new infrastructure, not a reuse of the scan pipeline.


3. Alternative proposal: Hold-as-CA (X.509)

Alternative proposal, separate from the ORAS-artifact design above. Not implemented. There is no CA code, no io.atcr.hold.coSignManifest or io.atcr.hold.reSignManifest endpoint, and no HOLD_COSIGN_* / HOLD_CA_* configuration in the hold.

This is a distinct, optional design for environments that require standard X.509 PKI (for example, tools that only speak Notation/Notary v2). It deliberately trades away decentralization, so it is presented as an alternative rather than the recommended path.

The problem it addresses

  • ATProto repository signatures use K-256.
  • Notation supports only P-256/P-384/P-521.
  • A K-256 signature cannot be converted to a P-256 one (different curves), so a second signature with a P-256 key would be required.

The proposed mechanism

A hold would act as a Certificate Authority:

  1. User pushes an image; the manifest is signed by the PDS with K-256 (as today).
  2. The AppView asks the hold to co-sign (proposed io.atcr.hold.coSignManifest).
  3. The hold verifies the ATProto K-256 signature is valid for the user's DID.
  4. The hold mints an ephemeral P-256 key pair and issues a short-lived X.509 certificate (subject CN=<did>, SAN URI:<did>) signed by the hold's CA key.
  5. The hold signs the manifest digest with the P-256 key and wraps it in a Notation JWS envelope (with the cert chain in the x5c header).
  6. The signature is published as an ORAS artifact (artifactType: application/vnd.cncf.notary.signature).

Proposed certificate chain:

Hold Root CA (self-signed, P-256, ~10y, CA=true pathLen=1)
  -> User certificate (CN=<did>, SAN URI:<did>, P-256, ~24h, Code Signing)
      -> Manifest signature

What is signed, clarified

Correction: the original HOLD_AS_CA.md was internally inconsistent about what the P-256 signature covers. The intended design is that the hold signs a hash derived from the manifest content/digest, producing a Notation-style detached signature whose payload is the manifest descriptor. The illustrative Go in the original that did SHA256([]byte(req.ManifestDigest)) (hashing the digest string) was a sketch, not a spec. A real implementation would sign over the manifest bytes / OCI descriptor per the Notation signing spec, not the ASCII of the digest string.

Proposed (non-existent) configuration

These environment variables are proposed names only; none are read by the hold today:

HOLD_COSIGN_ENABLED=true
HOLD_CA_CERT_PATH=/var/lib/atcr/hold/ca-certificate.pem
HOLD_CA_KEY_PATH=/var/lib/atcr/hold/ca-private-key.pem
HOLD_CERT_VALIDITY_HOURS=24
HOLD_OCSP_ENABLED=true
HOLD_CRL_ENABLED=true

Why this is the non-default proposal

Aspect Hold-as-CA ORAS-artifact + custom verifier
Standard tool compatibility Notation works unchanged Requires a custom verifier/plugin
Decentralization Centralized (hold is the CA) Decentralized (DID-based)
ATProto alignment Against the model Native
Signature reuse Must re-sign with P-256 Reuses existing K-256 commit signature
Compromise blast radius Hold key compromise affects all users Metadata only
Operational overhead High (CA, CRL/OCSP, trust distribution) Low

Threat highlights for the CA design: compromise of the hold's CA private key is catastrophic (an attacker could mint certificates for any DID); a malicious hold operator could issue certificates without genuinely verifying the ATProto signature. Mitigations discussed in the original (HSM-backed CA key, short cert lifetimes, transparency logging, cross-checking against the ATProto signature) remain valid design considerations but are unbuilt.


4. Proposed tool: atcr-verify CLI

Proposal / specification. Not implemented. There is no cmd/atcr-verify directory and no pkg/verify package. There are no release binaries, no container image, and no version output. The commands, flags, and transcripts below describe a tool that does not exist yet.

A standalone verifier is the keystone of the decentralized (non-CA) approach: it would discover the signature artifact, follow it to the ATProto record, resolve the DID, fetch the commit, and verify the K-256 signature, then apply a trust policy.

Proposed verification flow

  1. Resolve the image reference to a manifest digest.
  2. Query the Referrers API for application/vnd.atproto.signature.v1+json artifacts and fetch the metadata blob.
  3. Resolve the DID to a public key.
  4. Query the PDS for the manifest record and its commit.
  5. Verify the K-256 commit signature against the public key.
  6. Evaluate a trust policy (trusted DIDs, max age, minimum signatures).

Proposed surface (illustrative only)

# proposed, not real
atcr-verify atcr.io/alice/myapp:latest
atcr-verify atcr.io/alice/myapp:latest --policy trust-policy.yaml
atcr-verify atcr.io/alice/myapp:latest --output json
atcr-verify atcr.io/alice/myapp:latest --quiet     # exit code only

Proposed flags: --policy, --output {text,json,quiet}, --cache-dir, --no-cache, --timeout, --verbose. Proposed exit codes: 0 verified, 1 failed, 2 bad args, 3 network error, 4 policy violation.

Proposed subcommands: verify (default), export (write a bundle), trust (manage trusted DIDs), version.

Proposed trust policy format

version: 1.0
defaultAction: enforce        # enforce | audit | allow
requireSignature: true

policies:
  - name: production-images
    scope: "atcr.io/*/prod-*"
    require:
      signature: true
      trustedDIDs: [did:plc:devops-team, did:plc:security-team]
      minSignatures: 1
      maxAge: 2592000         # 30 days, seconds
    action: enforce
  - name: dev-images
    scope: "atcr.io/*/dev-*"
    require:
      signature: false
    action: audit

trustedDIDs:
  did:plc:devops-team:
    name: "DevOps Team"
    validFrom: "2024-01-01T00:00:00Z"
    expiresAt: null

Offline verification is a proposal, not a current capability

Correction: the original docs contradicted each other on offline verification. One described full --offline --bundle support as a feature; another listed ATProto offline verification as "Limited" with a footnote that it "can be improved by embedding signature bytes in the ORAS blob". The accurate statement: offline verification does not exist. It is possible in principle if the signature artifact embeds the commit bytes and the relevant DID document so a verifier need not reach the PLC directory or PDS at run time. Treat any --offline/export bundle mention as a future design idea.

Proposed library usage

If built, the verifier package would import as atcr.io/pkg/verify (the module is atcr.io, not github.com/atcr-io/atcr). The original docs used the wrong module path throughout; see corrections.


5. Integration notes (hypothetical examples)

Everything in this section is hypothetical. The atcr-verify binary, container image, Referrers API, and signature artifacts they assume do not exist. The examples are retained as design sketches for the integration surfaces that would matter if the foundation were built. The Ratify and Gatekeeper plugin skeletons referenced here live under examples/plugins/ and are likewise unbuilt scaffolding.

Tool reality check

Standard OCI tools could, given the proposed artifacts, discover but not verify ATProto signatures:

Tool Could discover Could fetch Could verify
oras discover yes - no
oras pull / crane manifest - yes no
cosign tree yes (as artifacts) - no
cosign verify / notation verify - - no (different format)
proposed atcr-verify yes yes yes

Kubernetes (proposed)

Several admission-control paths were sketched, all depending on the proposed verifier:

  • A validating admission webhook that runs verification per container image and rejects pods on failure.
  • A Ratify verifier plugin (CanVerify on application/vnd.atproto.signature.v1+json, then resolve DID, fetch commit, verify K-256, check trust policy). Skeleton under examples/plugins/ratify-verifier/.
  • An OPA Gatekeeper external data provider that calls a verification service and returns verified: true/false for Rego policies. Skeleton under examples/plugins/gatekeeper-provider/.
  • A Containerd 2.0 bindir image-verifier that shells out to atcr-verify at pull time.

CI/CD (proposed)

GitHub Actions / GitLab CI snippets would install the (nonexistent) atcr-verify binary or use a (nonexistent) atcr.io/atcr/verify image and fail the build on a bad signature. They are placeholders until the CLI exists.


6. Trust model and security considerations

This section is accurate at the conceptual level and applies to any design built on ATProto signatures.

What an ATProto signature proves

  • Authenticity: the manifest record was committed by the DID owner's PDS.
  • Integrity: the manifest content has not been altered since signing (content-addressed; tampering changes the CID).
  • Timestamp: when the commit was made.

What it does not prove

  • That the image is free of vulnerabilities (that is what scanning is for).
  • That the DID owner is authorized to deploy anywhere.
  • That the signing key was not compromised.
  • That the PDS is honest about anything other than the signed bytes (it cannot forge a signature without the private key, but availability and record selection still depend on it).

Trust dependencies

  1. DID resolution returns the correct public key for the DID.
  2. The PDS is reachable to fetch the commit (unless offline verification is built per the proposal in section 4).
  3. K-256 remains secure.
  4. The verifier's trust policy lists only legitimately trusted DIDs.

Threat sketches

  • MITM on PDS queries: defeated by signature verification (attacker cannot forge the signature) plus TLS.
  • Compromised/malicious PDS serving fake manifests: signature verification fails.
  • Key compromise: same posture as any PKI; rotate keys via DID document updates.
  • Replay/rollback: check that the commit is in the current repository DAG and inspect timestamps.
  • DID takeover via rotation keys: serious but requires compromising rotation keys, which is harder than the signing key.

7. Comparison with other signing solutions

Feature ATCR (ATProto) Cosign (Sigstore) Notation (Notary v2)
Signing Inherent (PDS commit) Manual or keyless Manual
Keys K-256 (secp256k1) P-256 or RSA P-256/P-384/P-521
Trust DID-based OIDC + Fulcio CA X.509 PKI
Storage of signature ATProto commit (today); ORAS artifact (proposed) OCI registry OCI registry
Centralization Decentralized Centralized (Fulcio) Configurable
Transparency log ATProto firehose Rekor Configurable
Offline verification Not built (possible per proposal) No Yes

Why not just use Cosign keyless?

ATProto and Cosign keyless use incompatible identity models. Cosign keyless needs OIDC + Fulcio + Rekor + TUF; ATProto uses DPoP-bound OAuth and DID-based PKI with no CA. Bridging would mean operating Fulcio/Rekor/TUF and an OIDC bridge for ATProto OAuth. The ATProto-native approach reuses the existing commit signature instead. (For tools that strictly require X.509, the Hold-as-CA alternative in section 3 exists as a deliberate, centralized fallback.)


8. Proposed implementation roadmap

Aspirational ordering, not a commitment. Effort estimates are from the original design docs and are unvalidated.

  1. Foundation: build the atcr-verify CLI (K-256 commit verification, trust policy, output formats) and the AppView OCI Referrers API + signature-artifact creation. Without these two, nothing else is testable.
  2. Kubernetes: OPA Gatekeeper external provider and Ratify verifier plugin.
  3. Runtime: Containerd 2.0 bindir verifier.
  4. Optional/enterprise: Hold-as-CA (only if X.509 compliance is actually demanded), plus advanced features (transparency log, multi-signature, offline bundles).

9. Corrections from review

The following factual problems in the original six docs were fixed during consolidation:

  • Module path. Originals used github.com/atcr-io/atcr for imports, go install, git clone, and release URLs. The actual Go module is atcr.io (see go.mod). The GitHub-style paths and release-download instructions were removed or relabeled as hypothetical.
  • SBOM storage. Originals claimed SBOMs are stored as ORAS artifacts and that signature artifacts would "follow the same pattern as SBOMs." False. SBOMs and scan results are io.atcr.hold.scan ATProto records in the hold's CAR store (pkg/hold/pds/scan.go), with the SBOM as a blob reference inside the record. There is no ORAS/Referrers pipeline for scans.
  • "Available Now" claims. IMAGE_SIGNING.md listed ATProto signature artifacts, ORAS artifacts, and the OCI Referrers API under "Available Now." None of these exist. All such claims were removed; the only thing real today is the PDS commit signature (section 1).
  • Fabricated CLI artifacts. ATCR_VERIFY_CLI.md shipped install commands, docker pull instructions, and a version output (atcr-verify version 1.0.0, a fake commit hash and build date). Removed; the CLI is a spec.
  • Offline verification contradiction. Resolved: offline verification is a proposal, not a current capability (section 4).
  • What the CA signs. The Hold-as-CA Go sketch hashed the digest string; the intended design signs over the manifest descriptor/bytes per Notation. Noted in section 3.
  • Cross-references. Links that pointed at the now-deleted sibling docs were consolidated into this single file. External example scaffolding (examples/verification/, examples/plugins/) still exists but is unbuilt; its READMEs were updated to point here.