Dropping my quick thoughts on AT Protocol, the promises by Bluesky
A few years ago, Twitter’s Jack Dorsey announced a project called Bluesky, intended to design and build a more decentralized social network system. Mastodon and ActivityPub are important to mention here. Technically, Mastodon is a software that implements Twitter-like functionality. However, unlike Twitter, Mastodon can communicate with other servers using the W3C ActivityPub protocol, creating a network of servers known as the Fediverse.
Bluesky, after nearly three years of work, has recently published the first draft of a protocol called the AT Protocol (ATP) which is designed as a social networking technology.
At the Google Developer Groups’ Go To Songdo Event, I had the opportunity to present and share insights on ATProtocol, a beta release from Bluesky, the emergent social network app. The presentation was aimed at exploring the intricacies of decentralized networks.
Personal Data Server (PDS) Layer
The fundamental concept behind the protocol is that each user has an account with a personal data server (PDS), where they can post content and read posts from others. These PDSs communicate with each other in a process known as a federation, with the goal of creating a unified network experience.
A key promise of the protocol is the concept of portable identity. Most distributed systems tie a user’s identity to the server they use. For example, an email address like sigrid.raabe@gmail.com is tied to Gmail, and can’t easily be moved to a different service like Hotmail. The protocol, however, allows a user’s identity to be portable between different PDSs. This means that if a user is blocked by a PDS, they can simply move their account to a different PDS, taking all their posts and followers with them.
Scaling is a challenge for any distributed system, particularly for a system like Twitter where messages are sent to large groups of people. If a user has a large number of followers spread across many different PDSs, their PDS needs to contact each follower’s PDS every time they post a tweet. This can become a significant challenge as the number of followers increases.
To address this, the protocol suggests a two-level system, with a second layer of crawling indexers who have access to all the data and can offer a personalized view. However, the documentation is currently vague on how this is supposed to work, with the reference to crawling being surprising. It would be more efficient for this kind of service to have special APIs that allow it to get a full feed of what’s happening, rather than acting like an ordinary PDS.
Identity System Layer
The identity system in the ATP protocol primarily revolves around the concept of a “handle”, which is a hierarchical name rooted in the Domain Name System (DNS), such as @sigridjin.com. This is akin to traditional systems like email or Jabber where a user’s identity would be expressed as sigrid@sigridjin.com.
However, due to the influence of platforms like Twitter, which uses the @ symbol to denote usernames, the ATP protocol has incorporated this into its user handle system. This has led to certain quirks such as the possibility of names with two @ symbols like @snoop@sigridjin.com or a name where the separator between the actual username and the domain it resides in is omitted.
This has created some ambiguity as to whether an identifier is a domain name or a username. For example, web.example.com could be interpreted as either. In theory, if an identifier has an @ symbol in front of it, it’s considered a username, but this isn’t always adhered to. Additionally, since domain names are hierarchical, it’s possible for the same identifier to be both a username and a domain name. For example, there could be a user ‘snoop’ on the domain ‘example.com’, but there could also be a subdomain ‘snoop.sigridjin.com’.
To resolve a handle, an RPC query is made to the endpoint associated with the domain name of the handle, which returns a Decentralized Identifier (DID). This DID can then be resolved to obtain the public key associated with the user, which is used to sign the user’s data.
The ATP protocol supports two types of DIDs out of the numerous variants currently specified:
did:web
: This type involves an HTTPS fetch to a website to retrieve the DID document, which contains the public key.did:plc
(DID Placeholder): This is a new DID form, which consists of a hash of a public key which can be used directly or to sign new public keys to allow rollover. However, it's not entirely clear how one obtains the DID document associated with a did:plc DID, as the public key alone isn't sufficient to retrieve it.
The security of the did:web resolution process depends on DNS security, but even if you use did:plc, the handle resolution process still depends on the DNS. This means that an attacker who controls the DNS or the handle server for a given DNS name can provide any DID of their choice, thus bypassing the cryptographic controls that did:plc or any similar mechanism use to provide verified rollover.
There seems to be some implicit assumption that clients (or other PDSes) will retrieve the DID associated with a handle and then remember it indefinitely. While this might be ideal, in practice, retaining this kind of state is often challenging. As such, it would be natural to treat it as soft state by caching it, but not worrying too much if it gets lost because you can always retrieve it.
The lack of clarity over whether the handle → DID mapping is invariant once it’s established or is expected to change can lead to confusion. ATP should either build in some certificate transparency-type mechanism to protect against compromise of the handle servers or admit that the security of ATP identity depends on the DNS. In the latter case, you could presumably skip the DID step entirely and just store the public key and associated data right on the handle server.
Finally, while the mechanism does enable data portability from one PDS to another, it doesn’t completely deliver on the censorship-resistance aspect of portability. Even though it’s probably cheaper to run a handle mapping server than a PDS, so you might be able to run that but outsource the PDS piece, it’s still likely that most people will just run them in the same place, which could lead to potential vulnerabilities.
Access Controls
Access control is an important aspect of any social networking or communication system. It’s the mechanism that allows users to restrict who can see their content. However, in the context of the ATP protocol, it’s not clear how this access control is implemented. The section on authentication in the documentation doesn’t provide any details. That being said, there are generally two potential approaches to implementing access control in such a system, although neither is without its drawbacks.
- Post encryption for each authorized reader: In this approach, when a user makes a post, it is separately encrypted for each person authorized to read it. The encryption is done using the public keys of all authorized readers. This approach is technically straightforward but operationally cumbersome. It requires knowing the public keys of all followers at the time of posting and being able to go back and retroactively encrypt posts for new followers or when existing followers change their keys.
- PDS-enforced access control: In this alternative approach, the Personal Data Servers (PDSs) themselves enforce access based on who is following a given user. This approach is less operationally cumbersome but requires a high degree of trust in the PDSs. For instance, if Alice is on PDS A and Bob and Charlie are on PDS B, and Alice restricts her posts so only Bob can read them, when Alice posts something, it gets sent to PDS B, which then has to ensure it’s only shown to Bob and not Charlie. This requires Alice to trust not just her own PDS, but also PDS B.
- What stops a PDS C, which doesn’t have any of Alice’s followers, from accessing Alice’s posts? The documents don’t specify the details on it.
User Creation Scenario
In this scenario, let’s say that there is PDS exists at https://atprotocol.sigridjin.com. The PDS will have one more keys that it uses. At least one of these keys is provided for public key verification of signatures.
Some of these keys may be “offline” keys for recovery purposes. Implications…
- The server should support multiple signing keys.
- The server should support requests to GET /.well-known/jwks.json
- It has two keys: online/active and offline
- PDS signing key (example):
z2dDWH6M6TMUy7Yxhh3XrUgZm1PdbGUnEVTkpyvj1LSMPSXaq1YFV2HRBVP98LPc1gtVPe8mhu1MRHTJc3UBZUoMP
- PDS recovering key (example):
z5FBMN38eEgdgX11ejXNn2KaZCx2ZCicJJLYLb8gdQqHpzaa4PuTpZDqyfxZc5dbyVNDAnLbLHuwyNxEkaeVtbWZU
The following golang code is for generating recovering key.
privateKey, err := ecdsa.GenerateKey(crypto.S256(), rand.Reader)
if err != nil {
fmt.Printf("Failed to generate private key: %s", err)
return
}
privateKeyBytes := crypto.FromECDSA(privateKey)
privateKeyBase58 := base58.Encode(privateKeyBytes)
fmt.Printf("private=%s\n", privateKeyBase58)
publicKey := privateKey.PublicKey
publicKeyBytes := crypto.FromECDSAPub(&publicKey)
publicKeyBase58 := base58.Encode(publicKeyBytes)
fmt.Printf("public=%s\n", publicKeyBase58)
hashedMessage := sha256.Sum256([]byte("message"))
signature, err := crypto.Sign(hashedMessage[:], privateKey)
if err != nil {
fmt.Printf("Failed to sign message: %s", err)
return
}
isValidSignature := crypto.VerifySignature(publicKeyBytes, hashedMessage[:], signature[:len(signature)-1])
if !isValidSignature {
fmt.Println("Failed to verify signature")
return
}
fmt.Println("Signature is valid") }
The generate private and public key is stored securely by the user and the public key is sent as a recovery_key parameter to the create account xrpc method.
private=z7F4fw9jCXtW3qxi6c3ew7y7LDRppvumSQc7Tp3bvzCyV
public=z2ZaygJJjGTMRYZDyHqLZwv7qneUDwu99X11JQ7DK8Z4ma28Ux9zFcW3qpMengmmxicBfMpqoDCu33EFiTPpxVb9y
The PDS is the first home for the user and the xprc method com.atproto.account.create is invoked as the following. Internally, the PDS creates a create operation (by JSON-RPC or XRPC).
{
"type": "create",
"services": {
"atproto_pds": {
"type": "AtprotoPersonalDataServer",
"endpoint": "https://atprotocol.sigridjin.com"
}
},
"alsoKnownAs": [
"at://golangkorea.sigridjin.com"
],
"rotationKeys": [
"did:key:z2dDWH6M6TMUy7Yxhh3XrUgZm1PdbGUnEVTkpyvj1LSMPSXaq1YFV2HRBVP98LPc1gtVPe8mhu1MRHTJc3UBZUoMP",
"did:key:z5FBMN38eEgdgX11ejXNn2KaZCx2ZCicJJLYLb8gdQqHpzaa4PuTpZDqyfxZc5dbyVNDAnLbLHuwyNxEkaeVtbWZU",
"did:key:z2ZaygJJjGTMRYZDyHqLZwv7qneUDwu99X11JQ7DK8Z4ma28Ux9zFcW3qpMengmmxicBfMpqoDCu33EFiTPpxVb9y"
],
"verificationMethods": {
"active-kid-1678556449": "did:key:z2dDWH6M6TMUy7Yxhh3XrUgZm1PdbGUnEVTkpyvj1LSMPSXaq1YFV2HRBVP98LPc1gtVPe8mhu1MRHTJc3UBZUoMP"
}
}
That create operation is used to create the DID of the user based on did:plc:${base32Encode(sha256(createOp)).slice(0,24)}
- The resulting did for the user is did:plc:rmi5khsuhsgktvxt6etk7ish.
The create operation is also signed and made available as a public log of operations.
The user can be resolved by making requests to the PDS
- GET https://X/xrpc/com.atproto.handle.resolve?handle=Y
- Where X is atprotocol.sigridjin.com
- Where Y is one of golangkorea
- golangkorea.atprotocol.sigridjin.com
- did:plc:rmi5khsuhsgktvxt6etk7ish
data, err := ioutil.ReadFile("golangkorea.atprotocol.sigridjin.com.create.json") // reads the data
if err != nil {
fmt.Printf("Failed to read file: %s", err)
return
}
hasher := sha256.New() // generates SHA256 hash of the file data
hasher.Write(data)
digest := hasher.Sum(nil)
encodedDigest := base32.StdEncoding.EncodeToString(digest)
encodedDigest = strings.ToLower(encodedDigest)
if len(encodedDigest) > 24 {
encodedDigest = encodedDigest[:24]
}
fmt.Println(encodedDigest) // prints the first 24 characters of the encoded hash
data, err := ioutil.ReadFile("golangkorea.atprotocol.sigridjin.com.create.json") // reads the data
if err != nil {
fmt.Printf("Failed to read file: %s", err)
return
}
hasher := sha256.New() // generates SHA256 hash of the file data
hasher.Write(data)
digest := hasher.Sum(nil)
encodedDigest := base32.StdEncoding.EncodeToString(digest)
encodedDigest = strings.ToLower(encodedDigest)
if len(encodedDigest) > 24 {
encodedDigest = encodedDigest[:24]
}
fmt.Println(encodedDigest) // prints the first 24 characters of the encoded hash
User Discovery Scenario (HTTPS-based)
In this scenario, a PDS exists at https://atprotocol.sigridjin.com
The user golangkorea.atprotocol.sigridjin.com references @pycon.kr in a post (lives in bsky).
The server has never encountered that user before, so it begins the user discovery process to reference the user and link to them accordingly.
The bsky.social PDS first does the com.atproto.handle.resolve XPRC call as GET https://bsky.social/xrpc/com.atproto.handle.resolve?handle=pycon.kr
- That returns a DID in the payload
{“did”: “did:plc:kjf4j5r3oqmi7c6uy3umto4g”}
.
The server then queries plc.directory to get the full DID for the user and learn where their PDS is as GET https://plc.directory/did:plc:kjf4j5r3oqmi7c6uy3umto4g.
At that point, the AtprotoPersonalDataServer is listed and available for https://bsky.social to interact with.
Thoughts
My initial thoughts on the protocol are half and half. In system design, starting with top-level questions is key. This approach helps build an architecture that addresses these queries before diving into specifics. It prevents details from obscuring larger issues & eases comprehension saving the audience from reverse engineering.
It is challenging to launch a competitive social network — not only due to technical difficulties but also due to network effects. New networks initially hold little value, especially those like Twitter, where value is derived from constant content feed. Building a non-federated system like Twitter is straightforward conceptually, but operating it at a vast scale presents challenges. This is a common pitfall, as seen in the case of Google Plus.
ActivityPub’s decentralized & minimalistic protocol design has proven effective. It remains to be seen how AT Protocol’s sophisticated approach will perform practically. We must always question the need for complexity. Additionally, implementing the protocol by its spec would deter devs to provide the real needs of users (e.g. direct messages).
All in all, I am generally sceptical of the success of its protocol in that when considering a BFT scenario, it might be more fitting to use Ethereum entirely. The AT Protocol, assuming a semi-BFT situation, might be in a vague position. It could be better to improve upon ActivityPub from a personal perspective.
Recurring issues observed in the post-Ethereum realm are evident here too. The p2p BFT nature, a significant consideration in Ethereum’s design, is similarly factored into this context. So far, there are no concepts that outshine Ethereum at the implementation/adoption level.
While an overview attempts to provide this high-level description, it often falls short by providing an introductory summary that leaves many larger questions unanswered. These questions would be easier to answer and understand with a more comprehensive description of the system’s operations.
- How does a Personal Data Store (PDS) become aware of new activity on another PDS?
- How do “crawlers” discover new PDSes and their respective content?
- How does the system handle access control? What happens if a post is set to private?
- What are the scaling properties of the system?
- What kind of security assurances are in place regarding identities and the integrity of the data?
- How does the system deal with various types of abuse? If someone is sending abusive messages, does each PDS or user have to block them individually, or is there a centralized reputation system in place?
Though to see development efforts that would prompt me to laud again Ethereum’s unwavering implementation progress, observing AT Protocol’s fame among earnest indie devs reminds me of the purity often seen in software modules which mirrors the unpretentious charm of Minecraft.