- 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
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-verifybinary, nopkg/verifypackage, no hold-as-CA code, noio.atcr.hold.coSignManifest/io.atcr.hold.reSignManifestXRPC endpoints, and noHOLD_COSIGN_*configuration. The CLI examples, install instructions,oras/cosigncommand 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
- What is real today
- Proposed design: ORAS signature artifacts + Referrers API
- Alternative proposal: Hold-as-CA (X.509)
- Proposed tool: atcr-verify CLI
- Integration notes (hypothetical examples)
- Trust model and security considerations
- Comparison with other signing solutions
- Proposed implementation roadmap
- 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:
- AppView stores the manifest as an
io.atcr.manifestrecord in the user's PDS. - The PDS creates a repository commit containing the new record in its Merkle Search Tree (MST).
- The PDS signs that commit with the repository's signing key (ECDSA over the secp256k1 / K-256 curve).
- 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:
- Resolve the image reference to a manifest digest.
- Find the corresponding
io.atcr.manifestrecord in the user's PDS (com.atproto.repo.getRecord/listRecords). - Fetch the repository (
com.atproto.sync.getRepo) and extract the signed commit covering that record. - Resolve the DID to its public key from the DID document.
- 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.scanATProto records in the hold's CAR store (seepkg/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.coSignManifestorio.atcr.hold.reSignManifestendpoint, and noHOLD_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:
- User pushes an image; the manifest is signed by the PDS with K-256 (as today).
- The AppView asks the hold to co-sign (proposed
io.atcr.hold.coSignManifest). - The hold verifies the ATProto K-256 signature is valid for the user's DID.
- The hold mints an ephemeral P-256 key pair and issues a short-lived X.509
certificate (subject
CN=<did>, SANURI:<did>) signed by the hold's CA key. - 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
x5cheader). - 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.mdwas 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 didSHA256([]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-verifydirectory and nopkg/verifypackage. 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
- Resolve the image reference to a manifest digest.
- Query the Referrers API for
application/vnd.atproto.signature.v1+jsonartifacts and fetch the metadata blob. - Resolve the DID to a public key.
- Query the PDS for the manifest record and its commit.
- Verify the K-256 commit signature against the public key.
- 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 --bundlesupport 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 bundlemention 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-verifybinary, 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 underexamples/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 (
CanVerifyonapplication/vnd.atproto.signature.v1+json, then resolve DID, fetch commit, verify K-256, check trust policy). Skeleton underexamples/plugins/ratify-verifier/. - An OPA Gatekeeper external data provider that calls a verification service and
returns
verified: true/falsefor Rego policies. Skeleton underexamples/plugins/gatekeeper-provider/. - A Containerd 2.0 bindir image-verifier that shells out to
atcr-verifyat 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
- DID resolution returns the correct public key for the DID.
- The PDS is reachable to fetch the commit (unless offline verification is built per the proposal in section 4).
- K-256 remains secure.
- 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.
- Foundation: build the
atcr-verifyCLI (K-256 commit verification, trust policy, output formats) and the AppView OCI Referrers API + signature-artifact creation. Without these two, nothing else is testable. - Kubernetes: OPA Gatekeeper external provider and Ratify verifier plugin.
- Runtime: Containerd 2.0 bindir verifier.
- 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/atcrfor imports,go install,git clone, and release URLs. The actual Go module isatcr.io(seego.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.scanATProto 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.mdlisted 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.mdshipped install commands,docker pullinstructions, and aversionoutput (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.