December 18, 2023

Understanding ActivityPub Part 4: Threads

The Mastodon mascot wearing a univerisity gown

This article is part of the series Understanding ActivityPub, which takes a look at the ActivityPub protocol through the lens of real-world examples. The protocol exchanges are taken from ActivityPub.Academy, a modified Mastodon instance that shows ActivityPub messages in real time (see the announcement post).

The following articles are available.

Introduction

Last week, Threads started connecting their service to the Fediverse. While the scope is currently very limited — only a handful of Threads accounts can be followed from the Fediverse, and we can only see their posts and not really interact with them — it still enables us to take a first look at their implementation of ActivityPub. This means investigating both the Activities that are sent between threads.net and other Fediverse instances, as well as the ActivityPub data. The investigation is more challenging than the ones in the previous parts of this series, for two reasons. One is the limited feature set that they enabled right now. It is not possible for us to create posts from Threads and capture them in the Activity Log. We are instead forced to wait for one of the enabled accounts to trigger activities and try to capture those. The other reason is that Threads is closed source. For the other services, if something seemed odd, it was always possible to look at the source code to see what the reason for the strange behavior was. With Threads, this is not possible. But even with these limitations, we can gather some first insights.

First contact

As we have seen in Part 1: Protocol Fundamentals, interaction with another instance usually starts with WebFinger. Fediverse accounts are generally represented by full mentions, like @alice@activitypub.academy, while ActivityPub only works with actor IDs. WebFinger acts as a translator from one to the other (I won’t go into details here, you can check out Part 1 for more details). Let’s compare the WebFinger response for @alice@activitypub.academy with the one for @mosseri@threads.net, the account of the Head of Instagram, and one of the few accounts that can be followed from the Fediverse.

Here is the response for @alice.

{
    "subject":"acct:alice@activitypub.academy",
    "aliases":[
        "https://activitypub.academy/@alice",
        "https://activitypub.academy/users/alice"
    ],
    "links":[
        {
            "rel":"http://webfinger.net/rel/profile-page",
            "type":"text/html",
            "href":"https://activitypub.academy/@alice"
        },
        {
            "rel":"self",
            "type":"application/activity+json",
            "href":"https://activitypub.academy/users/alice"
        },
        {
            "rel":"http://ostatus.org/schema/1.0/subscribe",
            "template":"https://activitypub.academy/authorize_interaction?uri={uri}"
        }
    ]
}

And here for @mosseri.

{
  "subject":"acct:mosseri@threads.net",
  "links":[
    {
      "href":"https://www.threads.net/ap/users/mosseri/",
      "rel":"self",
      "type":"application/activity+json"
    }
  ]
}

Note: You can follow the interactions in this blog post on ActivityPub.Academy. In the ActivityPub Explorer, you can type in the full mention, for example @mosseri@threads.net, and it will be automatically translated into the corresponding WebFinger URL.

We can see that the Threads response has a lot less information than the Mastodon response. It only provides a link to the user’s actor ID. But this is enough for ActivityPub to work.

We now use the actor ID to get the actor’s profile. For Mastodon and for many other Fediverse services, this works by executing a GET request against the actor ID and supplying the Accept header application/ld+json; profile="https://www.w3.org/ns/activitystreams". But for Threads, this is not enough. It also requires the request to be signed using HTTP Signatures. We have already seen this requirement in Part 1: Protocol Fundamentals for a different part of the protocol: when Activities are sent to other Fediverse instances, the requests always have to be signed, otherwise they are discarded. To create and verify these signatures, every actor has a key pair. The private key is stored securely on the instance, and the public key is attached to the actor’s profile.

Threads’ requirement to sign these requests is not really special. Other Fediverse services require this as well, for example GoToSocial, or Mastodon when it is configured in “secure mode”. It provides a way to control who can and cannot access your data, so it is not surprising that Threads demands this. What is special though is the implementation of this requirement. To understand what this is, we have to take a little detour to discuss the instance actor.

Update on 2023-12-20: The special behavior seems to have been a bug on Threads’ side. It is now fixed and behaves according to the standard. Regardless of this, the instance actor is an important concept, so the following section is still of interest.

The Instance Actor

Most Fediverse instances have an actor that is not tied to any user, but instead to the instance itself. On Mastodon, it has its own username, which is the domain name itself. For example, for the Academy, the instance actor is @activitypub.academy@activitypub.academy (enter this full mention in the ActivityPub Explorer to take a look at the WebFinger response). This actor is special in many ways. For example, its actor ID does not follow the usual Mastodon naming schema, it is simply https://activitypub.academy/actor, and if you check out this profile in the ActivityPub Explorer, you see that the actor is of type Application. (This brings the number of actor types we have seen so far in this series up to three. We have already seen Person actors, which correspond to regular accounts, and in Part 2: Lemmy we have seen Group actors, which in Lemmy’s case represent communities.)

One of the main reasons to have an instance actor is to solve the following problem. Assume for a moment that activitypub.academy also required HTTP signatures to access its ActivityPub data. Now let’s say that @alice wants to follow @mosseri. She sends a Follow activity to @mosseri’s inbox and signs it with her private key. threads.net has to verify the signature. To do this, it needs to fetch @alice’s public key, which is attached to her profile. Since we assume that activitypub.academy requires HTTP signatures for profile fetches, threads.net has to sign the request to fetch @alice’s profile. But which key should it use for the signature? If it used @mosseri’s key, then activitypub.academy would need to fetch his profile, which again requires a signature, etc. This would create an infinite loop.

A diagram showing the infinite loop of public key fetches between activitypub.academy and threads.net

This is where the instance actor comes in. We have already seen that the instance actor is special in many ways. Another one is that profile fetches for the instance actor never have to be signed, even if the instance otherwise requires signatures. So the flow is like this. As before, @alice sends a Follow request to @mosseri’s inbox, signed with her private key. Since threads.net needs to verify the signature, it fetches @alice’s profile and signs the fetch request with the private key of the instance actor. This signature needs to be verified by activitypub.academy; it fetches the profile of the instance actor, but doesn’t sign it. Because profile fetches for the instance actor never have to be signed, threads.net sends the profile back, including the public key of the instance actor. This allows activitypub.academy to verify the signature on the request for @alice’s profile. It sends the profile to threads.net, which uses the public key to verify @alice’s initial request containing the Follow activity.

A diagram showing how the instance actor breaks the infinite fetch loop

Not all Fediverse instances require HTTP signatures for requests to the ActivityPub data. But it is impossible to know before a request is made. This is why Mastodon always signs these requests with the private key of its instance actor. But using the instance actor’s private key for the signature is not a requirement dictated by any spec (at least as far as I know), it’s a workaround for the infinite loop in case the both instances require HTTP signatures on profile fetches. If one of them doesn’t, then we could sign the request with the private key of any actor, without breaking any spec and without making the protocol less secure. And now we are back at Threads’ special implementation of the signature requirement.

Update on 2023-12-20: The special behavior seems to have been a bug on Threads’ side that is now fixed (before, Threads would only accept signatures from the Academy’s instance actor, not from any other actor). Thanks to Jahfer for looking into this.

A close look at the actor profile

Now that we know how to sign HTTP requests, we can take a look at some ActivityPub data.

After entering @mosseri’s actor ID in the ActivityPub Explorer, we get the following profile

{
    "@context":[
        "https://www.w3.org/ns/activitystreams",
        "https://w3id.org/security/v1"
    ],
    "id":"https://www.threads.net/ap/users/mosseri/",
    "type":"Person",
    "name":"Adam Mosseri",
    "preferredUsername":"mosseri",
    "summary":"<p>Head of @instagram, father of three boys, married up.</p>",
    "url":"https://www.threads.net/@mosseri/",
    "inbox":"https://www.threads.net/ap/users/mosseri/inbox/",
    "outbox":"https://www.threads.net/ap/users/mosseri/outbox/",
    "followers":"https://www.threads.net/ap/users/mosseri/followers/",
    "following":"https://www.threads.net/ap/users/mosseri/following/",
    "endpoints":{
        "sharedInbox":"https://www.threads.net/ap/inbox/"
    },
    "publicKey":{
        "id":"https://www.threads.net/ap/users/mosseri/#main-key",
        "owner":"https://www.threads.net/ap/users/mosseri/",
        "publicKeyPem":"-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAz95PKWeuwefMLfZZr22D gFrVXkLLTfvKthX4kS0tWFQVZckkTaYQ6ycNWlNT2wAoRuOWGzrwVOxDgkN+O3SW EcSgjbbdAsgDkNZQ2002ojDmQmgQkfhyMZvd4bY31+BMABnl9UYKgDXdZ8VHbsu/ A3a7EipN+1zyoUMM4YtoD4SSzozIf0LiBQxvUlEUbOG0pUCBNw+qVPOJgTEABiUX i8YpySTQxm/ALI+k7fBdd2p1hi9a5VaMSnTyaSfT+O6IjWsWF2K4HVBOqItG2eG3 eUyI2Apn99qmsxMtgb9kUMHYA8fvfjAwH4EQcpw3HojxTiMxa+cwg8P3Tr7TxXBQ /wIDAQAB -----END PUBLIC KEY----- "
    },
    "icon":{
        "type":"Image",
        "url":"https://scontent-ber1-1.cdninstagram.com/v/t51.2885-19/343392897_618515990300243_8088199406170073086_n.jpg?stp=dst-jpg_s400x400&_nc_cat=1&ccb=1-7&_nc_sid=c4dd86&_nc_ohc=jfliACgQoAkAX_uchQl&_nc_ht=scontent-ber1-1.cdninstagram.com&oh=00_AfDWePzFEWBGEmXVqeZKSF1_G-eHaUMJQCQAMvcoCF1UsA&oe=6583BACD"
    }
}

For comparison, here is @alice’s profile

{
    "@context":[
        "https://www.w3.org/ns/activitystreams",
        "https://w3id.org/security/v1",
        {
            "manuallyApprovesFollowers":"as:manuallyApprovesFollowers",
            "toot":"http://joinmastodon.org/ns#",
            "featured":{
                "@id":"toot:featured",
                "@type":"@id"
            },
            "featuredTags":{
                "@id":"toot:featuredTags",
                "@type":"@id"
            },
            "alsoKnownAs":{
                "@id":"as:alsoKnownAs",
                "@type":"@id"
            },
            "movedTo":{
                "@id":"as:movedTo",
                "@type":"@id"
            },
            "schema":"http://schema.org#",
            "PropertyValue":"schema:PropertyValue",
            "value":"schema:value",
            "discoverable":"toot:discoverable",
            "Device":"toot:Device",
            "Ed25519Signature":"toot:Ed25519Signature",
            "Ed25519Key":"toot:Ed25519Key",
            "Curve25519Key":"toot:Curve25519Key",
            "EncryptedMessage":"toot:EncryptedMessage",
            "publicKeyBase64":"toot:publicKeyBase64",
            "deviceId":"toot:deviceId",
            "claim":{
                "@type":"@id",
                "@id":"toot:claim"
            },
            "fingerprintKey":{
                "@type":"@id",
                "@id":"toot:fingerprintKey"
            },
            "identityKey":{
                "@type":"@id",
                "@id":"toot:identityKey"
            },
            "devices":{
                "@type":"@id",
                "@id":"toot:devices"
            },
            "messageFranking":"toot:messageFranking",
            "messageType":"toot:messageType",
            "cipherText":"toot:cipherText",
            "suspended":"toot:suspended"
        }
    ],
    "id":"https://activitypub.academy/users/alice",
    "type":"Person",
    "following":"https://activitypub.academy/users/alice/following",
    "followers":"https://activitypub.academy/users/alice/followers",
    "inbox":"https://activitypub.academy/users/alice/inbox",
    "outbox":"https://activitypub.academy/users/alice/outbox",
    "featured":"https://activitypub.academy/users/alice/collections/featured",
    "featuredTags":"https://activitypub.academy/users/alice/collections/tags",
    "preferredUsername":"alice",
    "name":"",
    "summary":"",
    "url":"https://activitypub.academy/@alice",
    "manuallyApprovesFollowers":false,
    "discoverable":false,
    "published":"2023-07-12T00:00:00Z",
    "devices":"https://activitypub.academy/users/alice/collections/devices",
    "publicKey":{
        "id":"https://activitypub.academy/users/alice#main-key",
        "owner":"https://activitypub.academy/users/alice",
        "publicKeyPem":"-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtmsar2mTtNSpTB6Y7oiX /snre/pt78k0MHFGZQMJvDoqcXs0aISU/dxD6D3xtG7tEjtax19mmHru3e4HwYLz 9Qq200Co/OPCx7ck4zwyH2LKXDNZHH/uw6DBlvS9ZZxCgputqLAZnmIUO1OfbS38 FgT3PPDA8xaTSU7S+8uZMLL3knqsS5mNkeLIbT2RwIjqrWiJpNIdZbz+UYyNBc36 HhJhh05xWT+VNhNOq2aWRegPDCcdYt0D4yMUhCL3kVyJZnhXuTRtAdbEPhMNhqEO 6MAy5F6zTrmEH99T1u/SryrTGh5ok1oidw6YdVRJUorysispZkAwXmY5QedMqhfJ fQIDAQAB -----END PUBLIC KEY----- "
    },
    "tag":[
    ],
    "attachment":[
    ],
    "endpoints":{
        "sharedInbox":"https://activitypub.academy/inbox"
    }
}

@alice’s profile seems a lot bigger, but it’s mostly related to the @context array. Threads doesn’t need most of those entries since it doesn’t use those features. When it comes to the “real” entries, both profiles look fairly similar. We see the inbox, outbox, and sharedInbox endpoints, and links to the followers and following collections. @alice’s profile has some entries which are not defined in the ActivityPub spec,like the featured and featuredTags collection. @mosseri’s profile doesn’t have those. It does have an icon entry though, which @alice’s profile does not have.

If we continue to the followers collection, we get

{
    "id":"https://www.threads.net/ap/users/mosseri/followers/",
    "type":"OrderedCollection",
    "totalItems":649959,
    "@context":[
        "https://www.w3.org/ns/activitystreams"
    ]
}

So we know that he has about 650 thousand followers, but we don’t know who those followers are. By contrast, @alice’s followers collection provides a paginated view of all of her followers. It’s a similar situation for @mosseri’s following collection. This means that the whole social graph is hidden. In Mastodon, this can happen as well. A user can decide to hide their social graph in the profile settings. If we queried this user’s followers collection, we would get a similar response: the number of followers, without the actual list. But in that case, the social graph would also be hidden in the UI. In @mosseri’s case, you can browse the list of his followers when you are using Threads; they are only hidden from the Fediverse. It will be interesting to see if this changes as they continue to develop their Fediverse connection.

As we expect by now, when we query the outbox endpoint, we only see the number of posts that @mosseri has sent, but not the actual posts themselves. Again, for @alice we can browse through her whole posting history.

First activities

Now let’s see if we can take a look at any activities generated by Threads. To do this, we have to follow one of the enabled accounts, so let @alice follow @mosseri. Here is the resulting Activity Log.

Nothing here stands out. We see a little bit of namespacing in the IDs, and the fact that Threads uses an array for @context, while Mastodon uses a string, but that’s about it. This is not really surprising, since the spec doesn’t allow a lot of wiggle room here.

And that’s about all we can do ourselves in the protocol right now. Everything else is currently a one way street. We can only wait for one of the activated Threads profiles to act and then look at the activities. Fortunately, I didn’t have to wait too long, and after a while saw the following activities in the log.

There were more posts, but I’m omitting them here. Let’s take a closer look at the first one. It looks like an activity that we would expect from any other Fediverse service. What’s more interesting is the data that is linked.

We see the @threads account being tagged and listed with its actor ID, but if we feed this into the ActivityPub Explorer, we get nothing back. This is not unexpected, since they so far enabled only a small number of accounts to be accessible through ActivityPub.

Each Note object has an id field and a url field. The url field (https://www.threads.net/@mosseri/post/C046LSmPAuN for the first post) contains the URL under which the post can be accessed in the Threads UI, while the id field (https://www.threads.net/ap/users/mosseri/post/18045787249507474/) is supposed to be a valid URL that is backed by ActivityPub data. And indeed, somewhat surprisingly given our experiences so far, when we enter this URL in the ActivityPub Explorer, we get back the Note object. So while we cannot browse through all posts via the outbox collection, we at least can fetch a single post if we have its ID.

Finally, maybe of note are the timestamps. The post was published at 21:43:47 UTC, but it was processed by the Academy at 21:49:13 UTC, which is a difference of about five and a half minutes. There is usually not a lot of activity on the academy, so the delay was probably mostly on their side, potentially because the post had to be sent out to many different instances.

Let’s stay with these messages for a little bit. When we view @mosseri’s thread in the Threads UI, we can see that the first post was edited. In the Activity Log, this shows as

This is exactly the same activity that was sent earlier, which seems strange. It would be interesting to know if this was supposed to be a real edit. Usually, we would expect an edit to result in an Update activity with the updated Note object inside. Since we cannot try this out ourselves, we will have to wait for a properly edited post.

There are at least two further Threads accounts that can be followed from the Fediverse, @christophersu@threads.net and @0xjessel@threads.net. @alice follows them both, and they provided two more activities to take a closer look at. The first is by @christophersu, which shows that deleting of posts is implemented as we would expect.

The other one is more interesting, because it shows how Threads represents quote posts in ActivityPub.

This shows that a quote post is a regular Note object, with the link to the quoted post included in the content field.

A screenshot of Threads, showing the quote post

A screenshot of the quote post as shown in Threads

Let’s compare this with quote posts from other Fediverse services. While Mastodon does not currently support quote posts, Firefish does. Here is the Activity log showing a quote of this post from Firefish.

The quote post from Threads looks very similar to the one from Firefish, so there’s a good chance that they took this as inspiration (there is no official standard on quote posts in ActivityPub). But there is an important detail. Firefish includes the ID of the quoted post in the quoteUrl field, while Threads does not. Using the quoteUrl field, Firefish can fetch the quoted post from the ActivityPub data and show it inline in the quote post.

A screenshot of Firefish, showing the quote post

A screenshot of the quote post as shown in Firefish

But with Threads’ quote post, this is not possible. Not only does it not contain a quoteUrl field, the URL embedded in the content leads to the Threads UI, it is not a valid ActivityPub ID. There is no way for example for Firefish to fetch the quoted post on the ActivityPub level. This means that quote posts from Threads are not fully supported in the Fediverse, even for services that support quote posts. The only way to see what was quoted is by following the link and opening it in the Threads UI.

Update on 2024-01-10: Threads has changed their format for quote posts, which now also makes it possible to fetch the quoted post from ActivityPub data. Here is an example.

Note that Threads doesn’t use the quoteUrl or quoteUri fields to reference the original post, but instead uses the _misskey_quote field. The reason according to @pcottle, one of the Threads engineers, is that neither of the three fields is officially supported by ActivityPub specs, and they chose _misskey_quote to make it obvious that this is an unofficial key. It is supported by at least Firefish and Misskey, and potentially other services that use quote posts.

If we query the value of the _misskey_quote field with the ActivityPub Explorer, it shows

{
  "id":"https://www.threads.net/ap/users/0xjessel/post/17845116579137110/",
    "type":"Note",
    "content":"<p>hard to believe <a href="https://threads.net/@threads/" class="u-url mention">@<span>threads</span></a> turns 6 months old today -- that launch week was the craziest experience ever. <br /><br />we still got lots more work to do though. time to build 💪<br /><br />RE: <a href="https://www.threads.net/@0xjessel/post/CuU5msWhJAQ" data-lnfb-mode="ie">https://www.threads.net/@0xjessel/post/CuU5msWhJAQ</a></p>",
    "published":"2024-01-05T10:37:13-0800",
    "attributedTo":"https://www.threads.net/ap/users/0xjessel/",
    "url":"https://www.threads.net/@0xjessel/post/C1uphLdSV9Y",
    "to":[
      "https://www.w3.org/ns/activitystreams#Public"
    ],
    "cc":[
      "https://www.threads.net/ap/users/0xjessel/followers/"
    ],
    "tag":[
    {
      "href":"https://threads.net/ap/users/17841459227751075/",
      "name":"@threads",
      "type":"Mention"
    }
    ],
    "@context":[
      "https://www.w3.org/ns/activitystreams"
    ]
}

which is exactly the post that’s reachable through the UI at https://www.threads.net/@0xjessel/post/C1uphLdSV9Y.

Conclusion

We have taken a first look at Threads’ implementation of ActivityPub. This turns out to be mostly what we expected, but there were some surprises, like the requirement that ActivityPub data fetches need to be signed by the instance actor (Update on 2023-12-20: this is not required anymore), or the way that Threads represents quote posts. For now, we had to rely on one of the few enabled accounts to trigger activities on their side so that we could investigate them. It will be interesting to come back to this once they enable arbitrary accounts to federate, so that we can do a proper investigation.

—Written by Sebastian Jambor. Follow me on Mastodon @crepels@mastodon.social for updates on new blog posts.