Hold-as-Certificate-Authority Architecture#
⚠️ Important Notice#
This document describes an optional enterprise feature for X.509 PKI compliance. The hold-as-CA approach introduces centralization trade-offs that contradict ATProto's decentralized philosophy.
Default Recommendation: Use plugin-based integration instead. Only implement hold-as-CA if your organization has specific X.509 PKI compliance requirements.
Overview#
The hold-as-CA architecture allows ATCR to generate Notation/Notary v2-compatible signatures by having hold services act as Certificate Authorities that issue X.509 certificates for users.
The Problem#
- ATProto signatures use K-256 (secp256k1) elliptic curve
- Notation only supports P-256, P-384, P-521 elliptic curves
- Cannot convert K-256 signatures to P-256 (different cryptographic curves)
- Must re-sign with P-256 keys for Notation compatibility
The Solution#
Hold services act as trusted Certificate Authorities (CAs):
- User pushes image → Manifest signed by PDS with K-256 (ATProto)
- Hold verifies ATProto signature is valid
- Hold generates ephemeral P-256 key pair for user
- Hold issues X.509 certificate to user's DID
- Hold signs manifest with P-256 key
- Hold creates Notation signature envelope (JWS format)
- Stores both ATProto and Notation signatures
Result: Images have two signatures:
- ATProto signature (K-256) - Decentralized, DID-based
- Notation signature (P-256) - Centralized, X.509 PKI
Architecture#
Certificate Chain#
Hold Root CA Certificate (self-signed, P-256)
└── User Certificate (issued to DID, P-256)
└── Image Manifest Signature
Hold Root CA:
Subject: CN=ATCR Hold CA - did:web:hold01.atcr.io
Issuer: Self (self-signed)
Key Usage: Digital Signature, Certificate Sign
Basic Constraints: CA=true, pathLen=1
Algorithm: ECDSA P-256
Validity: 10 years
User Certificate:
Subject: CN=did:plc:alice123
SAN: URI:did:plc:alice123
Issuer: Hold Root CA
Key Usage: Digital Signature
Extended Key Usage: Code Signing
Algorithm: ECDSA P-256
Validity: 24 hours (short-lived)
Push Flow#
┌──────────────────────────────────────────────────────┐
│ 1. User: docker push atcr.io/alice/myapp:latest │
└────────────────────┬─────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────┐
│ 2. AppView stores manifest in alice's PDS │
│ - PDS signs with K-256 (ATProto standard) │
│ - Signature stored in repository commit │
└────────────────────┬─────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────┐
│ 3. AppView requests hold to co-sign │
│ POST /xrpc/io.atcr.hold.coSignManifest │
│ { │
│ "userDid": "did:plc:alice123", │
│ "manifestDigest": "sha256:abc123...", │
│ "atprotoSignature": {...} │
│ } │
└────────────────────┬─────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────┐
│ 4. Hold verifies ATProto signature │
│ a. Resolve alice's DID → public key │
│ b. Fetch commit from alice's PDS │
│ c. Verify K-256 signature │
│ d. Ensure signature is valid │
│ │
│ If verification fails → REJECT │
└────────────────────┬─────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────┐
│ 5. Hold generates ephemeral P-256 key pair │
│ privateKey := ecdsa.GenerateKey(elliptic.P256()) │
└────────────────────┬─────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────┐
│ 6. Hold issues X.509 certificate │
│ Subject: CN=did:plc:alice123 │
│ SAN: URI:did:plc:alice123 │
│ Issuer: Hold CA │
│ NotBefore: now │
│ NotAfter: now + 24 hours │
│ KeyUsage: Digital Signature │
│ ExtKeyUsage: Code Signing │
│ │
│ Sign certificate with hold's CA private key │
└────────────────────┬─────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────┐
│ 7. Hold signs manifest digest │
│ hash := SHA256(manifestBytes) │
│ signature := ECDSA_P256(hash, privateKey) │
└────────────────────┬─────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────┐
│ 8. Hold creates Notation JWS envelope │
│ { │
│ "protected": {...}, │
│ "payload": "base64(manifestDigest)", │
│ "signature": "base64(p256Signature)", │
│ "header": { │
│ "x5c": [ │
│ "base64(userCert)", │
│ "base64(holdCACert)" │
│ ] │
│ } │
│ } │
└────────────────────┬─────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────┐
│ 9. Hold returns signature to AppView │
└────────────────────┬─────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────┐
│ 10. AppView stores Notation signature │
│ - Create ORAS artifact manifest │
│ - Upload JWS envelope as layer blob │
│ - Link to image via subject field │
│ - artifactType: application/vnd.cncf.notary... │
└──────────────────────────────────────────────────────┘
Verification Flow#
┌──────────────────────────────────────────────────────┐
│ User: notation verify atcr.io/alice/myapp:latest │
└────────────────────┬─────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────┐
│ 1. Notation queries Referrers API │
│ GET /v2/alice/myapp/referrers/sha256:abc123 │
│ → Discovers Notation signature artifact │
└────────────────────┬─────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────┐
│ 2. Notation downloads JWS envelope │
│ - Parses JSON Web Signature │
│ - Extracts certificate chain from x5c header │
└────────────────────┬─────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────┐
│ 3. Notation validates certificate chain │
│ a. User cert issued by Hold CA? ✓ │
│ b. Hold CA cert in trust store? ✓ │
│ c. Certificate not expired? ✓ │
│ d. Key usage correct? ✓ │
│ e. Subject matches policy? ✓ │
└────────────────────┬─────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────┐
│ 4. Notation verifies signature │
│ a. Extract public key from user certificate │
│ b. Compute manifest hash: SHA256(manifest) │
│ c. Verify: ECDSA_P256(hash, sig, pubKey) ✓ │
└────────────────────┬─────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────┐
│ 5. Success: Image verified ✓ │
│ Signed by: did:plc:alice123 (via Hold CA) │
└──────────────────────────────────────────────────────┘
Implementation#
Hold CA Certificate Generation#
// cmd/hold/main.go - CA initialization
func (h *Hold) initializeCA(ctx context.Context) error {
caKeyPath := filepath.Join(h.config.DataDir, "ca-private-key.pem")
caCertPath := filepath.Join(h.config.DataDir, "ca-certificate.pem")
// Load existing CA or generate new one
if exists(caKeyPath) && exists(caCertPath) {
h.caKey = loadPrivateKey(caKeyPath)
h.caCert = loadCertificate(caCertPath)
return nil
}
// Generate P-256 key pair for CA
caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return fmt.Errorf("failed to generate CA key: %w", err)
}
// Create CA certificate template
serialNumber, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
template := &x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
CommonName: fmt.Sprintf("ATCR Hold CA - %s", h.DID),
},
NotBefore: time.Now(),
NotAfter: time.Now().AddDate(10, 0, 0), // 10 years
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
BasicConstraintsValid: true,
IsCA: true,
MaxPathLen: 1, // Can only issue end-entity certificates
}
// Self-sign
certDER, err := x509.CreateCertificate(
rand.Reader,
template,
template, // Self-signed: issuer = subject
&caKey.PublicKey,
caKey,
)
if err != nil {
return fmt.Errorf("failed to create CA certificate: %w", err)
}
caCert, _ := x509.ParseCertificate(certDER)
// Save to disk (0600 permissions)
savePrivateKey(caKeyPath, caKey)
saveCertificate(caCertPath, caCert)
h.caKey = caKey
h.caCert = caCert
log.Info("Generated new CA certificate", "did", h.DID, "expires", caCert.NotAfter)
return nil
}
User Certificate Issuance#
// pkg/hold/cosign.go
func (h *Hold) issueUserCertificate(userDID string) (*x509.Certificate, *ecdsa.PrivateKey, error) {
// Generate ephemeral P-256 key for user
userKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, nil, fmt.Errorf("failed to generate user key: %w", err)
}
serialNumber, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
// Parse DID for SAN
sanURI, _ := url.Parse(userDID)
template := &x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
CommonName: userDID,
},
URIs: []*url.URL{sanURI}, // Subject Alternative Name
NotBefore: time.Now(),
NotAfter: time.Now().Add(24 * time.Hour), // Short-lived: 24 hours
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning},
BasicConstraintsValid: true,
IsCA: false,
}
// Sign with hold's CA key
certDER, err := x509.CreateCertificate(
rand.Reader,
template,
h.caCert, // Issuer: Hold CA
&userKey.PublicKey,
h.caKey, // Sign with CA private key
)
if err != nil {
return nil, nil, fmt.Errorf("failed to create user certificate: %w", err)
}
userCert, _ := x509.ParseCertificate(certDER)
return userCert, userKey, nil
}
Co-Signing XRPC Endpoint#
// pkg/hold/oci/xrpc.go
func (s *Server) handleCoSignManifest(ctx context.Context, req *CoSignRequest) (*CoSignResponse, error) {
// 1. Verify caller is authenticated
did, err := s.auth.VerifyToken(ctx, req.Token)
if err != nil {
return nil, fmt.Errorf("authentication failed: %w", err)
}
// 2. Verify ATProto signature
valid, err := s.verifyATProtoSignature(ctx, req.UserDID, req.ManifestDigest, req.ATProtoSignature)
if err != nil || !valid {
return nil, fmt.Errorf("ATProto signature verification failed: %w", err)
}
// 3. Issue certificate for user
userCert, userKey, err := s.hold.issueUserCertificate(req.UserDID)
if err != nil {
return nil, fmt.Errorf("failed to issue certificate: %w", err)
}
// 4. Sign manifest with user's key
manifestHash := sha256.Sum256([]byte(req.ManifestDigest))
signature, err := ecdsa.SignASN1(rand.Reader, userKey, manifestHash[:])
if err != nil {
return nil, fmt.Errorf("failed to sign manifest: %w", err)
}
// 5. Create JWS envelope
jws, err := s.createJWSEnvelope(signature, userCert, s.hold.caCert, req.ManifestDigest)
if err != nil {
return nil, fmt.Errorf("failed to create JWS: %w", err)
}
return &CoSignResponse{
JWS: jws,
Certificate: encodeCertificate(userCert),
CACertificate: encodeCertificate(s.hold.caCert),
}, nil
}
Trust Model#
Centralization Analysis#
ATProto Model (Decentralized):
- Each PDS is independent
- User controls which PDS to use
- Trust user's DID, not specific infrastructure
- PDS compromise affects only that PDS's users
- Multiple PDSs provide redundancy
Hold-as-CA Model (Centralized):
- Hold acts as single Certificate Authority
- All users must trust hold's CA certificate
- Hold compromise = attacker can issue certificates for ANY user
- Hold becomes single point of failure
- Users depend on hold operator honesty
What Hold Vouches For#
When hold issues a certificate, it attests:
✅ "I verified that [DID] signed this manifest with ATProto"
- Hold validated ATProto signature
- Hold confirmed signature matches user's DID
- Hold checked signature at specific time
❌ "This image is safe"
- Hold does NOT audit image contents
- Certificate ≠ vulnerability scan
- Signature ≠ security guarantee
❌ "I control this DID"
- Hold does NOT control user's DID
- DID ownership is independent
- Hold cannot revoke DIDs
Threat Model#
Scenario 1: Hold Private Key Compromise
Attack:
- Attacker steals hold's CA private key
- Can issue certificates for any DID
- Can sign malicious images as any user
Impact:
- CRITICAL - All users affected
- Attacker can impersonate any user
- All signatures become untrustworthy
Detection:
- Certificate Transparency logs (if implemented)
- Unusual certificate issuance patterns
- Users report unexpected signatures
Mitigation:
- Store CA key in Hardware Security Module (HSM)
- Strict access controls
- Audit logging
- Regular key rotation
Recovery:
- Revoke compromised CA certificate
- Generate new CA certificate
- Re-issue all active certificates
- Notify all users
- Update trust stores
Scenario 2: Malicious Hold Operator
Attack:
- Hold operator issues certificates without verifying ATProto signatures
- Hold operator signs malicious images
- Hold operator backdates certificates
Impact:
- HIGH - Trust model broken
- Users receive signed malicious images
- Difficult to detect without ATProto cross-check
Detection:
- Compare Notation signature timestamp with ATProto commit time
- Verify ATProto signature exists independently
- Monitor hold's signing patterns
Mitigation:
- Audit trail linking certificates to ATProto signatures
- Public transparency logs
- Multi-signature requirements
- Periodically verify ATProto signatures
Recovery:
- Identify malicious certificates
- Revoke hold's CA trust
- Switch to different hold
- Re-verify all images
Scenario 3: Certificate Theft
Attack:
- Attacker steals issued user certificate + private key
- Uses it to sign malicious images
Impact:
- LOW-MEDIUM - Limited scope
- Affects only specific user/image
- Short validity period (24 hours)
Detection:
- Unexpected signature timestamps
- Images signed from unknown locations
Mitigation:
- Short certificate validity (24 hours)
- Ephemeral keys (not stored long-term)
- Certificate revocation if detected
Recovery:
- Wait for certificate expiration (24 hours)
- Revoke specific certificate
- Investigate compromise source
Certificate Management#
Expiration Strategy#
Short-Lived Certificates (24 hours):
Pros:
- ✅ Minimal revocation infrastructure needed
- ✅ Compromise window is tiny
- ✅ Automatic cleanup
- ✅ Lower CRL/OCSP overhead
Cons:
- ❌ Old images become unverifiable quickly
- ❌ Requires re-signing for historical verification
- ❌ Storage: multiple signatures for same image
Solution: On-Demand Re-Signing
User pulls old image → Notation verification fails (expired cert)
→ User requests re-signing: POST /xrpc/io.atcr.hold.reSignManifest
→ Hold verifies ATProto signature still valid
→ Hold issues new certificate (24 hours)
→ Hold creates new Notation signature
→ User can verify with fresh certificate
Revocation#
Certificate Revocation List (CRL):
Hold publishes CRL at: https://hold01.atcr.io/ca.crl
Notation configured to check CRL:
{
"trustPolicies": [{
"name": "atcr-images",
"signatureVerification": {
"verificationLevel": "strict",
"override": {
"revocationValidation": "strict"
}
}
}]
}
OCSP (Online Certificate Status Protocol):
- Hold runs OCSP responder:
https://hold01.atcr.io/ocsp - Real-time certificate status checks
- Lower overhead than CRL downloads
Revocation Triggers:
- Key compromise detected
- Malicious signing detected
- User request
- DID ownership change
CA Key Rotation#
Rotation Procedure:
- Generate new CA key pair
- Create new CA certificate
- Cross-sign old CA with new CA (transition period)
- Distribute new CA certificate to all users
- Begin issuing with new CA for new signatures
- Grace period (30 days): Accept both old and new CA
- Retire old CA after grace period
Frequency: Every 2-3 years (longer than short-lived certs)
Trust Store Distribution#
Problem#
Users must add hold's CA certificate to their Notation trust store for verification to work.
Manual Distribution#
# 1. Download hold's CA certificate
curl https://hold01.atcr.io/ca.crt -o hold01-ca.crt
# 2. Verify fingerprint (out-of-band)
openssl x509 -in hold01-ca.crt -fingerprint -noout
# Compare with published fingerprint
# 3. Add to Notation trust store
notation cert add --type ca --store atcr-holds hold01-ca.crt
Automated Distribution#
ATCR CLI tool:
atcr trust add hold01.atcr.io
# → Fetches CA certificate
# → Verifies via HTTPS + DNSSEC
# → Adds to Notation trust store
# → Configures trust policy
atcr trust list
# → Shows trusted holds with fingerprints
System-Wide Trust#
For enterprise deployments:
Debian/Ubuntu:
# Install CA certificate system-wide
cp hold01-ca.crt /usr/local/share/ca-certificates/atcr-hold01.crt
update-ca-certificates
RHEL/CentOS:
cp hold01-ca.crt /etc/pki/ca-trust/source/anchors/
update-ca-trust
Container images:
FROM ubuntu:22.04
COPY hold01-ca.crt /usr/local/share/ca-certificates/
RUN update-ca-certificates
Configuration#
Hold Service#
Environment variables:
# Enable co-signing feature
HOLD_COSIGN_ENABLED=true
# CA certificate and key paths
HOLD_CA_CERT_PATH=/var/lib/atcr/hold/ca-certificate.pem
HOLD_CA_KEY_PATH=/var/lib/atcr/hold/ca-private-key.pem
# Certificate validity
HOLD_CERT_VALIDITY_HOURS=24
# OCSP responder
HOLD_OCSP_ENABLED=true
HOLD_OCSP_URL=https://hold01.atcr.io/ocsp
# CRL distribution
HOLD_CRL_ENABLED=true
HOLD_CRL_URL=https://hold01.atcr.io/ca.crl
Notation Trust Policy#
{
"version": "1.0",
"trustPolicies": [{
"name": "atcr-images",
"registryScopes": ["atcr.io/*/*"],
"signatureVerification": {
"level": "strict",
"override": {
"revocationValidation": "strict"
}
},
"trustStores": ["ca:atcr-holds"],
"trustedIdentities": [
"x509.subject: CN=did:plc:*",
"x509.subject: CN=did:web:*"
]
}]
}
When to Use Hold-as-CA#
✅ Use When#
Enterprise X.509 PKI Compliance:
- Organization requires standard X.509 certificates
- Existing security policies mandate PKI
- Audit requirements for certificate chains
- Integration with existing CA infrastructure
Tool Compatibility:
- Must use standard Notation without plugins
- Cannot deploy custom verification tools
- Existing tooling expects X.509 signatures
Centralized Trust Acceptable:
- Organization already uses centralized trust model
- Hold operator is internal/trusted team
- Centralization risk is acceptable trade-off
❌ Don't Use When#
Default Deployment:
- Most users should use plugin-based approach
- Plugins maintain decentralization
- Plugins reuse existing ATProto signatures
Small Teams / Startups:
- Certificate management overhead too high
- Don't need X.509 compliance
- Prefer simpler architecture
Maximum Decentralization Required:
- Cannot accept hold as single trust point
- Must maintain pure ATProto model
- Centralization contradicts project goals
Comparison: Hold-as-CA vs. Plugins#
| Aspect | Hold-as-CA | Plugin Approach |
|---|---|---|
| Standard compliance | ✅ Full X.509/PKI | ⚠️ Custom verification |
| Tool compatibility | ✅ Notation works unchanged | ❌ Requires plugin install |
| Decentralization | ❌ Centralized (hold CA) | ✅ Decentralized (DIDs) |
| ATProto alignment | ❌ Against philosophy | ✅ ATProto-native |
| Signature reuse | ❌ Must re-sign (P-256) | ✅ Reuses ATProto (K-256) |
| Certificate mgmt | 🔴 High overhead | 🟢 None |
| Trust distribution | 🔴 Must distribute CA cert | 🟢 DID resolution |
| Hold compromise | 🔴 All users affected | 🟢 Metadata only |
| Operational cost | 🔴 High | 🟢 Low |
| Use case | Enterprise PKI | General purpose |
Recommendations#
Default Approach: Plugins#
For most deployments, use plugin-based verification:
- Ratify plugin for Kubernetes
- OPA Gatekeeper provider for policy enforcement
- Containerd verifier for runtime checks
- atcr-verify CLI for general purpose
See Integration Strategy for details.
Optional: Hold-as-CA for Enterprise#
Only implement hold-as-CA if you have specific requirements:
- Enterprise X.509 PKI mandates
- Cannot use plugins (restricted environments)
- Accept centralization trade-off
Implement as opt-in feature:
# Users explicitly enable co-signing
docker push atcr.io/alice/myapp:latest --sign=notation
# Or via environment variable
export ATCR_ENABLE_COSIGN=true
docker push atcr.io/alice/myapp:latest
Security Best Practices#
If implementing hold-as-CA:
- Store CA key in HSM - Never on filesystem
- Audit all certificate issuance - Log every cert
- Public transparency log - Publish all certificates
- Short certificate validity - 24 hours max
- Monitor unusual patterns - Alert on anomalies
- Regular CA key rotation - Every 2-3 years
- Cross-check ATProto - Verify both signatures match
- Incident response plan - Prepare for compromise
See Also#
- ATProto Signatures - How ATProto signing works
- Integration Strategy - Overview of integration approaches
- Signature Integration - Tool-specific integration guides