Unforgeable Endorsements Technical Deep-Dive

This is Part 2 of our technical deep-dive into building cryptographically-verified endorsements on ATProtocol. Read Part 1 for the overview →

In Part 1, we explored the high-level architecture of our two-record endorsement system. Now let's dive into the implementation details that make this system cryptographically unforgeable.

Content Identifiers: The Cryptographic Foundation

At the heart of our system is ATProtocol's use of Content Identifiers (CIDs). A CID is like a fingerprint of your data—but way better. Traditional database IDs are arbitrary numbers assigned by a central authority. CIDs are cryptographic hashes computed from the content itself. Same content always produces same CID. Different content produces different CID. And here's the kicker: it's computationally infeasible to create fake content that matches a given CID.

Step-by-Step CID Computation

Here's exactly how ATProtocol computes a CID for an endorsement record:

Step 1: Serialize to DAG-CBOR
Record data → Deterministic binary format
{
"$type": "place.atwork.endorsement",
"giver": "did:plc:alice123",
"receiver": "did:plc:bob456",
"text": "Bob designed our microservices platform...",
"createdAt": "2025-10-12T14:30:00Z"
}
→ [binary DAG-CBOR bytes]

Step 2: Hash with SHA-256
Binary bytes → 32-byte hash digest
→ e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

Step 3: Create Multihash
Hash + algorithm ID + length
→ 0x12 (SHA-256) + 0x20 (32 bytes) + digest

Step 4: Add Codec and Version
→ 0x01 (CIDv1) + 0x71 (dag-cbor) + multihash

Step 5: Encode to Base32
→ "bafyreib7h3j2ixgz4e5b..."

The critical insight: Step 1 uses DAG-CBOR, a deterministic serialization format. This means everyone who serializes the same data structure gets the exact same bytes. If serialization were non-deterministic (like standard JSON where key ordering can vary), different people computing the CID would get different results.

Why Signatures Are Excluded

Here's a subtle but crucial point: the CID is computed from the unsigned content. Signatures are stored separately in ATProtocol's repository structure.

Think about why. If signatures were included in the content before CID computation, you'd have a chicken-and-egg problem:

To sign the data, you need to know what you're signing

The data includes the CID

But the CID depends on the signature

Which depends on the data...

Infinite loop, system explodes. Instead, ATProtocol separates concerns:

Compute CID from canonical unsigned content

Sign the CID (or the bytes that produced it)

Store signature in repository metadata

Implementation: The Complete Workflow

Let's implement the complete endorsement lifecycle with actual code.

Phase 1: Draft Creation (Application State)

// In Alice's app - create draft in application database (not PDS yet)
draft = {
id: "01JBCDEF...", // ULID
giver: "did:plc:alice123",
receiver: "did:plc:bob456",
text: "Bob designed our entire microservices platform...",
status: "drafting",
proof_aturi: null,
proof_cid: null,
created_at: "2025-10-12T14:30:00Z"
}

// Store in application database, NOT published to PDS yet
await storage.createEndorsementDraft(draft)

Nothing is on ATProtocol yet. The draft exists only in the application's database.

Phase 2: Giver Finalizes with Cryptographic Commitment

// 1. Compute CID of endorsement content (without signatures)
endorsementContent = {
giver: draft.giver,
receiver: draft.receiver,
text: draft.text,
createdAt: draft.created_at
}
proof_cid = computeEndorsementCID(endorsementContent)

// 2. Create proof record in Alice's PDS
proofURI = await createRecord({
repo: "did:plc:alice123",
collection: "place.atwork.endorsementProof",
record: {
cid: proof_cid
}
})

// This triggers:
// 1. Proof record serialized to DAG-CBOR
// 2. Added to Alice's repository Merkle tree
// 3. New commit signed by Alice
// 4. Broadcast to ATProtocol firehose

// 3. Update draft to mark as "drafted" with proof info
draft.status = "drafted"
draft.proof_aturi = proofURI
draft.proof_cid = proof_cid
await storage.updateEndorsementDraft(draft)

// 4. Notify Bob (via app-specific mechanism)
sendNotification(bob, {
type: "endorsement_received",
from: alice,
draft_id: draft.id
})

Phase 3: Receiver Reviews and Accepts

// 1. Bob creates the endorsement record in his PDS (receiver's repo)
endorsementURI = await createRecord({
repo: "did:plc:bob456",
collection: "place.atwork.endorsement",
record: {
giver: "did:plc:alice123",
receiver: "did:plc:bob456",
text: "Bob designed our entire microservices platform...",
createdAt: "2025-10-12T14:30:00Z",
signatures: [{
uri: draft.proof_aturi, // Alice's proof AT-URI
cid: draft.proof_cid // Alice's proof CID
}]
}
})

// This triggers:
// 1. Endorsement serialized to DAG-CBOR
// 2. Added to Bob's repository Merkle tree
// 3. New commit signed by Bob
// 4. Broadcast to ATProtocol firehose

// 2. Delete the draft (no longer needed)
await storage.deleteEndorsementDraft(draft.id)

The Validation Algorithm

When someone claims "Here's Bob's endorsement with Alice's proof," here's the complete validation implementation:

function validateEndorsement(endorsementRecord, proofRecord):
// 1. Extract the proof CID from Bob's endorsement signatures field
proofStrongRef = endorsementRecord.signatures[0]
expectedCID = proofStrongRef.cid

// 2. Recompute CID from endorsement content (without signatures)
endorsementContent = {
giver: endorsementRecord.giver,
receiver: endorsementRecord.receiver,
text: endorsementRecord.text,
createdAt: endorsementRecord.createdAt
}
canonicalBytes = encodeDagCBOR(endorsementContent)
computedHash = sha256(canonicalBytes)
computedCID = createCID(computedHash)

// 3. Verify it matches Alice's proof CID
if computedCID != proofRecord.cid:
return INVALID("Proof CID doesn't match computed endorsement CID")

// 4. Verify it matches what Bob referenced
if computedCID != expectedCID:
return INVALID("Bob's reference doesn't match Alice's proof")

// 5. Verify signatures in ATProtocol repositories
if !verifyRepoSignature(aliceRepo, proofRecord):
return INVALID("Alice's proof signature invalid")

if !verifyRepoSignature(bobRepo, endorsementRecord):
return INVALID("Bob's endorsement signature invalid")

return VALID

Asynchronous Validation via Firehose

Your app subscribes to the firehose and processes events in real-time:

// Firehose subscription handler
onFirehoseCommit(event):
if event.collection == "place.atwork.endorsement":
// New endorsement created by receiver
endorsement = event.record

// Extract the proof reference from signatures
if endorsement.signatures and endorsement.signatures.length > 0:
proofRef = endorsement.signatures[0]

// Fetch proof record from giver's PDS
proof = await fetchRecord(proofRef.uri)

// Validate the cryptographic chain
if validateEndorsement(endorsement, proof):
// Update local database to mark as verified
db.createOrUpdateEndorsement({
aturi: event.uri,
giver: endorsement.giver,
receiver: endorsement.receiver,
text: endorsement.text,
validation_state: "valid",
proof_uri: proofRef.uri,
validated_at: now()
})

if event.collection == "place.atwork.endorsementProof":
// New proof created by giver
proof = event.record

// Store proof for later validation when endorsement is created
db.createEndorsementProof({
aturi: event.uri,
giver_did: event.repo,
cid: proof.cid,
created_at: now()
})

Record Updates and Re-Validation

ATProtocol repositories are mutable, but endorsement records should be immutable by design. If Alice updates her proof to commit to different content:

// Original proof
await createRecord({
repo: "did:plc:alice123",
collection: "place.atwork.endorsementProof",
record: {
cid: "bafyreiabc..." // CID of original endorsement content
}
})

// Later, Alice tries to "update" it
await updateRecord({
repo: "did:plc:alice123",
collection: "place.atwork.endorsementProof",
rkey: proofRecordKey,
record: {
cid: "bafyreixyz..." // Different CID
}
})

This breaks validation. Bob's endorsement still references the old proof CID. Apps will see the mismatch and mark the endorsement as invalid.

Re-Validation Implementation

function revalidateEndorsement(endorsementRecord):
// 1. Get the proof reference from endorsement
if !endorsementRecord.signatures or endorsementRecord.signatures.length == 0:
return "INVALID" // No proof signature

proofRef = endorsementRecord.signatures[0]

// 2. Fetch latest proof from giver's repo
latestProof = await fetchLatestRecord(proofRef.uri)

if !latestProof:
return "INVALID" // Proof was deleted

// 3. Compute CID from endorsement content (without signatures)
endorsementContent = {
giver: endorsementRecord.giver,
receiver: endorsementRecord.receiver,
text: endorsementRecord.text,
createdAt: endorsementRecord.createdAt
}
computedCID = computeEndorsementCID(endorsementContent)

// 4. Verify proof CID matches computed CID
if computedCID != latestProof.cid:
return "INVALID" // Proof doesn't commit to this content

// 5. Verify proof CID matches what endorsement references
if computedCID != proofRef.cid:
return "INVALID" // Reference mismatch

return "VALID"

Validation State Machine

ValidationStates:
DRAFT - Created by giver, not published
PENDING - Published, awaiting receiver proof
VALID - Both records exist, CIDs match, signatures valid
INVALID - CID mismatch or missing records
REJECTED - Receiver explicitly rejected
DELETED - One or both parties deleted records

State Transitions:
DRAFT → (publish) → PENDING
PENDING → (proof created) → VALID
PENDING → (explicit rejection) → REJECTED
VALID → (proof deleted) → INVALID
VALID → (endorsement deleted) → DELETED
INVALID → (re-validation succeeds) → VALID

Security Analysis: Attack Scenarios

Let's examine each attack vector in detail:

Forgery Attack

Attempt: Eve creates a fake proof claiming Alice endorsed Bob.

Implementation defense:

// Attack fails at validation
if proofRecord.repo != endorsement.giver:
return INVALID("Proof not from claimed giver")

Repudiation Attack

Attempt: Alice creates a proof, then claims she never did.

Implementation defense:

// Proof was broadcast and cached
cachedProof = db.getProof(proofURI)
if cachedProof && verifySignature(cachedProof, aliceDID):
// Mathematical proof Alice created it
return PROVEN_AUTHORSHIP

Modification Attack

Attempt: Eve modifies endorsement text after Alice's proof.

Implementation defense:

// Any modification changes the CID
originalCID = computeCID(originalContent)
modifiedCID = computeCID(modifiedContent)
// originalCID != modifiedCID
// Validation fails

Man-in-the-Middle

Attempt: Eve intercepts and modifies Alice's proof.

Implementation defense:

Proof is in Alice's signed repository

Repository commits are cryptographically signed

Modification invalidates signature chain

Replay Attack

Attempt: Eve reuses Alice's proof for different receiver.

Implementation defense:

// CID includes receiver DID
content = {
giver: "did:plc:alice123",
receiver: "did:plc:bob456", // Bound to Bob
text: "..."
}
// Can't change receiver without invalidating CID

Lexicon Definitions

Here are the complete lexicon definitions for implementation:

// place.atwork.endorsementProof
{
"lexicon": 1,
"id": "place.atwork.endorsementProof",
"defs": {
"main": {
"type": "record",
"key": "literal:self",
"record": {
"type": "object",
"required": ["cid"],
"properties": {
"cid": {
"type": "string",
"description": "CID of the endorsement content"
}
}
}
}
}
}

// place.atwork.endorsement
{
"lexicon": 1,
"id": "place.atwork.endorsement",
"defs": {
"main": {
"type": "record",
"key": "literal:self",
"record": {
"type": "object",
"required": ["giver", "receiver", "text", "createdAt", "signatures"],
"properties": {
"giver": {
"type": "string",
"format": "did"
},
"receiver": {
"type": "string",
"format": "did"
},
"text": {
"type": "string",
"maxLength": 10000
},
"createdAt": {
"type": "string",
"format": "datetime"
},
"signatures": {
"type": "array",
"items": {
"type": "ref",
"ref": "com.atproto.repo.strongRef"
}
}
}
}
}
}
}

Conclusion

We've built a system where cryptographic math, not corporate databases, determines truth. The two-record architecture with CID-based mutual attestation creates unforgeable professional endorsements on fully decentralized infrastructure.

The key insights:

Content addressing (CIDs) creates unforgeable commitments

Two-record separation enables asynchronous consent

Firehose architecture allows autonomous validation

Repository signatures provide cryptographic proof of authorship

This pattern extends beyond endorsements to any scenario requiring mutual attestation: contracts, credentials, reviews, verifications. ATProtocol provides the primitives—now build something where nobody can forge or deny what happened.

The future of professional reputation is cryptographically-verified and radically decentralized. The code is the specification. The math is the truth.