The Path to Decentralized Identity in ActivityPub

As ActivityPub was formulated as a successor to OStatus, through a more formalized standardization process (versus OStatus created as a byproduct of Identica), it's always been a work-in-progress. Even upon ratification as a W3C Recommended standard there were still many things left out that were intended to be left up to implementators to figure out on their own.

One thing intentionally left out was of detailing a specific method of authenticating ActivityPub data. In absence of that, one common mechanisms to authenticate data is with the use of HTTP Signatures (there's also LD Signatures, which has been retracted). However, given that HTTP Signatures are done through side-channel communication (solely via HTTP headers) and is commonly implemented to prevent replayability, it ends up lacking usefulness for relaying anything another transmission hop further. Lastly, due to the nature of ActivityPub solely being implemented to use URL identifiers that rely on DNS, it creates challenges with data and identity portability.

Continued Community-based Protocol Evolution

Despite ActivityPub always being a work-in-progress, the specific W3C working group (the SocialWG) dedicated to it's development had been closed down. Meanwhile, an unofficial community-led effort had been formulated to establish extensions to ActivityPub, to continue it's development and evolution, under a system known as Fediverse Enhancement Proposals (FEPs).

FEPs acts as a somewhat informal merit-based process where implementators recommend and discuss how to codify new ideas in a way that fits within the existing design patterns of ActivityPub. Within recent proposals, there's ideas to lay the groundwork for other methods of authenticating federated data, which can solve a lot of design issues that cause the common complaints people have with ActivityPub-based federated platforms.

Further, some of these extensions also builds upon other recent developments in internet standards, such as the Verifiable Credential Data Integrity (VCDI) standard, which is intended as a successor to the retracted LD Signatures standard; as well as the Decentralized Identifier (DIDs) standards that could enable full account/data portability between servers.

These internet standards are rolled into the ActivityPub ecosystem via proposals such as:

FEP-521a Key Representation

First, there's one roadblock that gets in the way of being able to make use of newer cryptographic standards, or even just to have more than one public key published per user. Presently, because of a relic from LD Signatures, most implementations expect one RSA public key for a user's "publicKey" attribute, and trying to modify the behavior of the field could break compatibility with existing software.

Therefore, in a means of progressive enhancement, as well as reusing existing vocabulary from the Verifiable Credential Data Integrity standard, there's the addition of the "assertionMethod" property to an ActivityPub actor, as outlined in FEP-521a and detailed in the VCDI standard. The public key value is stored in "publicKeyMultibase" and represented in Multikey serialization.

{
    "@context": [
        "https://www.w3.org/ns/activitystreams",
        "https://www.w3.org/ns/did/v1",
        "https://w3id.org/security/multikey/v1"
    ],
    "type": "Person",
    "id": "https://server.example/users/alice",
    "inbox": "https://server.example/users/alice/inbox",
    "outbox": "https://server.example/users/alice/outbox",
    "assertionMethod": [
        {
            "id": "https://server.example/users/alice#ed25519-key",
            "type": "Multikey",
            "controller": "https://server.example/users/alice",
            "publicKeyMultibase": "z6MkrJVnaZkeFzdQyMZu1cgjg7k1pZZ6pvBQ7XJPt4swbTQ2"
        }
    ]
}
Example of ActivityPub actor with a Multibase-encoded public key, in VCDI packaging

Multibase, Multikey, and eddsa-jcs-2022 Explained

Instead of using a verbose PEM-encoded representation of a public key (a public key wrapped with ----START PUBLIC KEY----- delimiters, and base64-encoded DER packaging), Multikey uses a much simpler format that's easier to encode/decode, rather than needing a full ASN.1 DER library just to extract a public key. Multikey is a form of Multibase-encoded data, whereas Multibase is a way to convey data in any variety of base-encodings. Two common forms are base-64-url-no-pad and base-58-btc. It uses the first single printable character to indicate the encoding type of value that follows. In the encoded value of a Multikey is a header that indicates the key type that follows.

A Multibase string starting with "u" denotes that the following string is "base-64-url-no-pad" encoded, which is a specific subset of base64 that doesn't require padding characters (e.g. it doesn't trail with '=' characters), and substitutes the '+' and '/' characters for '-' and '_' respectively.

While a Multibase string starting with "z" (as recommended in FEP-521a) denotes the following string is "base-58-btc" encoded, which uses a narrower set that omits any characters that could be visually confused for others, as it omits the numeral '0', uppercase 'O', lowercase 'l', and uppercase 'I' characters, and requiring no symbols.

Nonetheless, when you've distinguished between whether it's base58 or base64, and decoded the rest of string (you must skip the first character, when fed into a base64/base58 decode function), you will be left with a sequence of binary data.

Next, with the binary form of the Multikey data, the next two decoded bytes act as a header for the key type that follows. As with the newer elliptic-curve cryptography standards, the key type also implies the key size, thus not needing a "key length" field, just only a "key type" indicator is sufficient. The corresponding header values (shown in hexidecimal form), and the respective key type and size it corresponds to are as follows:

Header Value Key Type Value Size
0xed01 Ed25519 public key 32 bytes
0x8024 P-256 compressed public key 33 bytes
0x8124 P-384 compressed public key 49 bytes

While for storage of private keys (you should never see these publicly, of course):

Header Value Key Type Value Size
0x8026 Ed25519 private key 32 bytes
0x8626 P-256 private key 32 bytes
0x8726 P-384 private key 48 bytes

With this, you should now be able to decode the key type and successfully extract the binary public key value from a Multikey, to then pass to whichever library of choice you have for cryptographic operations (such as libsodium, or others).

Object Signing with Object Integrity Proofs

Now that we understand a framework for publishing an actor's public key, the next part is being able to actually sign ActivityPub objects, as proposed in FEP-8b32 (Object Integrity Proofs). However, before being able to sign/verify anything: because of the nature of JSON, and how things may be encoded/decoded in any varying order, it's best to establish a way to deterministically serialize JSON data that always results in the same output. Therefore, a set of rules known as JCS (JSON Canonicalization Scheme, RFC 8785) is laid out. A light summary of the important parts are as follows:

Within the specification, there's even a code example of a JCS canonicalizer in achieved in barely 30 lines of code:
https://www.rfc-editor.org/rfc/rfc8785#name-ecmascript-sample-canonical

As a basic kitchen sink example of JCS canonicalization, with an input like this:

{
  "species": ["canis", "lupus", "arctos"],
  "animal": "arctic wolf"
  "carnivoric": true
}

would end up always being canonicalized as:

{"animal":"artic wolf","carnivoric":true,"species":["canis","lupus","arctos"]}

So now with a way to deterministically encode any JSON data into a string that's always the same output, we can now cryptographically hash the canonicalized JSON output, to then be able to digitally sign it or verify a signature. We'll informally call the result of this hashed output as hashData.

Next, we need to construct a DataIntegrityProof object, to store the signature parameters (and later, the output), which would start to look something like this:

{
  "type": "DataIntegrityProof",
  "cryptosuite": "eddsa-jcs-2022",
  "verificationMethod": "https://server.example/users/alice#ed25519-key",
  "proofPurpose": "assertionMethod",
  "created": "2023-02-24T23:36:38Z"
}

verificationMethod would correspond with the ID of the key it's being signed with, created is just metadata of when it was (or shall be) signed, "proofPurpose": "assertionMethod" explains that the point of the signature is solely for signing authenticity of an object, "cryptosuite": "eddsa-jcs-2022" specifies the Data Integrity crypto suite that was used to sign the object, whereas eddsa-jcs-2022 dictates strictly: EdDSA signing, SHA-256 hashing, JCS canonicalization.

Now with a DataIntegrityProof object constructed, we JCS that object as well, SHA-256 hash it, and the result we'll refer to as proofConfigHash. So now with having the outputs hashData and proofConfigHash, we concatenate those two binary hash values together (resulting in a 64 byte value), and digitally sign that. Essentially the operation could be represented as:

proofBytes = eddsa_sign( hashData + proofConfigHash, edDSAKey )

As a result of the operation, you'd have a 64 byte signature value, which you would then Base58 encode, and prepend with a "z" character. To clarify: the resulting encoded signature value would be a Multibase value that's just encoded raw without any special headers, as the value is to be contained in the DataIntegrityProof object, which would describe the nature of the signature (eddsa-jcs-2022), therefore it'd be redundant to encode that same information again. Nonetheless, with the Multibase-encoded signature output, you can assign the value to the DataIntegrityProof object with a parameter name of proofValue, and embed the DataIntegrityProof object into the ActivityPub object under a property name of proof, resulting in a fully-signed object like so:

{
    "@context": [
        "https://www.w3.org/ns/activitystreams",
        "https://w3id.org/security/data-integrity/v1"
    ],
    "type": "Create",
    "actor": "https://server.example/users/alice",
    "object": {
        "type": "Note",
        "content": "Hello world"
    },
    "proof": {
        "type": "DataIntegrityProof",
        "cryptosuite": "eddsa-jcs-2022",
        "verificationMethod": "https://server.example/users/alice#ed25519-key",
        "proofPurpose": "assertionMethod",
        "proofValue": "z3sXaxjKs4M3BRicwWA9peyNPJvJqxtGsDmpt1jjoHCjgeUf71TRFz56osPSfDErszyLp5Ks1EhYSgpDaNM977Rg2",
        "created": "2023-02-24T23:36:38Z"
    }
}

Meanwhile, for signature verification, much of the same in reverse:

Deep Implementation Note: Despite feeding exactly a 64 byte message to a EdDSA sign/verify function, it's still expected that it internally performs a SHA-512 hash on the message. If for some reason you implement Ed25519 cryptographic functions yourself, make sure to not skip doing a SHA-512 hash of the message.

DIDs and Identity Proofs

With this framework of object signing and key representation, we can extend this further into a more abstract concept of creating identities canonically identified by a public key instead. One form of creating such identifiers are Decentralized Identifiers, or DIDs, as formulated into a standard by the W3C in 2022. A DID is a colon-delimited identifier, lightly similar to the nature of URNs, but starts with "did:", followed by a keyword that identifies the DID type, and then the identifier itself. One format is did:key, which is a public-key based identifier that's encoded as a Multibase key, e.g. did:key:z6MkrJVnaZkeFzdQyMZu1cgjg7k1pZZ6pvBQ7XJPt4swbTQ2.

But first, we need to establish a reciprocal assertion of a did:key-based identifier, by generating a proof of our ActivityPub actor object. All of the exact same proof-generation procedure applies as mentioned in Object Signing, however this time, for the key identifier, instead of an HTTPS URL, we use a did:key identifier instead, and also, we embed the DataIntegrityProof inside of a VerifiableIdentityStatement object, listed in the actor's attachments like so (as outlined in FEP-c390):

{
    "@context": [
        "https://www.w3.org/ns/activitystreams",
        "https://www.w3.org/ns/did/v1",
        "https://w3id.org/security/data-integrity/v1",
        {
            "fep": "https://w3id.org/fep#",
            "VerifiableIdentityStatement": "fep:VerifiableIdentityStatement",
            "subject": "fep:subject"
        }
    ],
    "type": "Person",
    "id": "https://server.example/users/alice",
    "inbox": "https://server.example/users/alice/inbox",
    "outbox": "https://server.example/users/alice/outbox",
    "attachment": [
        {
            "type": "VerifiableIdentityStatement",
            "subject": "did:key:z6MkrJVnaZkeFzdQyMZu1cgjg7k1pZZ6pvBQ7XJPt4swbTQ2",
            "alsoKnownAs": "https://server.example/users/alice",
            "proof": {
                "type": "DataIntegrityProof",
                "cryptosuite": "eddsa-jcs-2022",
                "created": "2023-02-24T23:36:38Z",
                "verificationMethod": "did:key:z6MkrJVnaZkeFzdQyMZu1cgjg7k1pZZ6pvBQ7XJPt4swbTQ2",
                "proofPurpose": "assertionMethod",
                "proofValue": "z26W7TfJYD9DrGqnem245zNbeCbTwjb8avpduzi1JPhFrwML99CpP6gGXSKSXAcQdpGFBXF4kx7VwtXKhu7VDZJ54"
            }
        }
    ]
}

With this, we now have the capacity to extend the usage of DIDs to canonically identify ActivityPub actors. However, instead it'd be ideal to use it for other forms of ActivityPub objects such as posts and other content authored by the actor. While it'd be convenient to apply did:key to other objects as well, especially as it seems that DIDs (per the base spec) can carry a URL-like syntax, it may be best instead to formulate a different but similar DID method.

Therefore, in FEP-ef61 (Portable Objects) a recommendation is made to formulate another DID method identified as did:apkey. It carries the same general encoding as did:key, however it contextualizes the meaning and context of the identifiers for content exchanged within the ActivityPub federated network (for example, if you had a URL-like DID identifier for an object, identified by did:key, how would you know by the DID URL itself of what protocol an object is to be retrieved through?)

From this, we have the capacity to expand the usage of DIDs from ActivityPub actors, to activities, objects, and so forth. Nonetheless, these FEPs are part of an active development, experimentation, and broader discussion of bringing true decentralization to ActivityPub. Therefore it's up to people to engage with implementing, adapting, and providing feedback and insight with the direction and formulation of these extensions in order to help ensure a well-refined solid standard.