# 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](#1-what-is-real-today) 2. [Proposed design: ORAS signature artifacts + Referrers API](#2-proposed-design-oras-signature-artifacts--referrers-api) 3. [Alternative proposal: Hold-as-CA (X.509)](#3-alternative-proposal-hold-as-ca-x509) 4. [Proposed tool: atcr-verify CLI](#4-proposed-tool-atcr-verify-cli) 5. [Integration notes (hypothetical examples)](#5-integration-notes-hypothetical-examples) 6. [Trust model and security considerations](#6-trust-model-and-security-considerations) 7. [Comparison with other signing solutions](#7-comparison-with-other-signing-solutions) 8. [Proposed implementation roadmap](#8-proposed-implementation-roadmap) 9. [Corrections from review](#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): ```go 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: ```json { "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:", "size": 1234 }, "layers": [ { "mediaType": "application/vnd.atproto.signature.v1+json", "digest": "sha256:", "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/", "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: ```json { "$type": "io.atcr.atproto.signature", "version": "1.0", "subject": { "digest": "sha256:", "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/", "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///referrers/sha256: ?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=`, SAN `URI:`) 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=, SAN URI:, 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) ```bash # 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 ```yaml 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.