A useful way to think about the DID standard is as a mechanism to bootstrap trust in an identifier. Implementers can define DID Methods that define how to resolve a DID Document from a DID URL. The most widely used DID method today is the Key DID.
This method is extremely simple and doesn’t have some of the more advanced features, such as key rotation, that the DID standard enables. However, there are still big challenges with using DIDs. Privacy being a big one. A wide range of different DID methods that are not compatible is another. This article explores how to build a DID method that is both composable (i.e. it builds on existing DID methods) while also adding privacy.
3ID and Its Limitations
While Ceramic supports multiple different DID methods, 3ID is the main DID method that leverages the network directly. 3ID has gone through two iterations so far. Let’s take a brief look at them, their limitations, and consider potential future iterations.
The Static 3IDv0
The first iteration of 3ID kept things super simple and worked similarly to a Key DID in that it was immutable. However, it added some flexibility by allowing the use of a variety of different key types in one DID document. For example, a secp256k1 key for signature verification and a x25519 key for receiving encrypted messages. These keys were simply stored in an IPLD object, the CID of which is used as the DID identifier. To resolve a 3IDv0 one simply needs to resolve the IPLD object from its CID, e.g. using IPFS.
The main limitation of 3IDv0 was that it was not possible to do any sort of key rotation. Once the DID is created the public keys provided up front are the keys for this DID forever.
More Flexibility With 3IDv1
The first version of 3ID was designed to enable key rotation and multi-account identity. By using the built-in conflict resolution of Ceramic’s streams, updating the DID document of a 3ID became straight forward. In this case, a Tile stream type represented the 3ID by storing the public keys in the JSON content of the Tile document. The StreamID of the Tile is then used as the DID identifier. By combining this updatable 3ID with another Ceramic stream type, caip10-links, multiple wallet accounts from different blockchains could be linked to a single 3ID.
While 3IDv1 is more flexible than its predecessor, there are still some significant limitations, primarily around privacy. All verification methods (e.g. public keys) in the DID document are public. There is no way to add a verification method that is unknown to the public observer. Furthermore, the caip10-links necessitated doxxing of the wallet account, creating a permanent link between the account and the 3ID, that even if removed would leave a trace in the caip10-link event log. Finally, UX for actually using a 3IDv1 was not optimal because the keys referenced in the DID Document were long lived. This led to weak security in a browser context and the reliance on an iframe for key management.
Desired Properties of 3IDv2
One of the most difficult things to achieve in a publicly verifiable, permissionless system is privacy. This is true for blockchains, and it’s also true for data networks like Ceramic. In blockchains, zero-knowledge proofs are being utilized to provide various sorts of privacy enhancements. Could the 3ID DID method utilize zkp and other techniques to achieve greater privacy as well? First, let’s start by defining what functionality would be desired in a privacy-focused 3ID:
- Ability to add and remove verification methods without publicly disclosing their public keys
- Selective disclosure of verification methods
- Ability to generate a signature issued by the 3ID without revealing which verification method was invoked
- Ability to privately graft (i.e. upgrade) existing DIDs to a 3ID (e.g. a caip10-link but private)
- Selective disclosure of grafted DIDs
Primitives for Composable Identity
Let’s have a look at what primitives we have available to us, or can construct, in order to achieve the properties described above.
Generative DID Methods
A generative DID method can be defined as a DID where the DID Document can be statically generated from the DID URL. This means that there is no need to look up state from a blockchain or do any other sort of network requests. It also means that it’s not possible to update or remove the DID Document since it is deterministically generated from the DID URL. There are two prominent examples of generative DID methods:
DID Key - every DID URL is a public key with an associated multicode to uniquely identify the key type. In order to generate the DID Document, the public key is simply translated into the appropriate verification method.
DID PKH - short for public key hash, this method essentially makes all blockchain accounts into DIDs. This is achieved by relying on the CAIP-10 account ID standard to identify a specific account on a specific blockchain. The DID URL gets translated to the DID Document using the special blockchain account verification method, which also relies on CAIP-10.
The stream type caip10-link in Ceramic was created before DID PKH existed in order to have a simple stream type controlled by a blockchain account. This functionality can now easily be replaced by any stream type, such as Tile Document or Model Instance Document by using DID PKH.
Signing every data transaction with your DID PKH (e.g. your crypto wallet) makes for less than ideal UX. In order to address this problem, object-capabilities (OCAP) and session keys can be used. The article on CACAO explains how this works in detail, but for context let’s have a look at how OCAPs can be used.
- A session key (DID Key) is randomly generated
- An authorization message (e.g. SIWE) is generated which contains the DID Key URL and an array of resources being authorized
- A signature request over this message is sent to the wallet (DID PKH)
- The message + signature can now be used as an OCAP, that together with the session key, can be used to invoke actions on the authorized resources
Important to note that these OCAPs can also be chained, e.g. Key A → Key B → Key C, where → implies a delegation.
Delegation using capabilities is powerful, but we don’t necessarily want delegations to be valid forever. A possible mitigation is to add an expiry time to the capability as it is issued. Time based revocation is simple but still quite limited and in the case of a compromised key, damage can still be caused before the capability expires. We need a way to check if a particular capability has been revoked by the issuer. Ideally without leaking information about which capabilities have existed over time—here a revocation registry can be useful. The capability itself can include a caveat that states that it is only valid if and only if the capability has not been added to the revocation registry.
On Ceramic the revocation registry could be implemented as follows:
- An event stream is created where revocation events can be published
- The revocation event is a multihash of the CID of the capability (an extra hash is added for privacy)
- Once a revocation event has been anchored it is considered valid and is enforceable starting from the anchor timestamp
- When a capability is issued it contains a caveat that references the revocation registry using its StreamID
- When the capability is verified, the revocation registry must be loaded from the Ceramic network; if the capability was revoked before it was invoked it is considered invalid
The lookup time of this revocation registry would be O(n). However, a Ceramic node could build a merkle tree where OCAP hashes are keys and revocation times are values to reduce lookup time to O(log n). In theory this could also make the revocations verifiable on-chain.
Private Capability Invocation
When a capability is invoked to perform some action it will generally be publicly disclosed. Up until the point of invocation it can be kept in secret, but if we want our system to remain publicly verifiable an observer must be able to validate the full delegation chain—from the owner of the resource to the key that performed the invocation. A keen reader might, however, realize that we don’t actually need to disclose the delegation chain if we use a ZKP to hide certain information.
publicInputs = (rootDID, sessionKey, ocapHashes, resources, actions)
privateInputs = (ocapChain)
The ZKP from above can be verified to ensure that the session key is indeed authorized to take certain actions on the given resources. The revocation registry can be referenced to make sure none of the capabilities are revoked since the ocapHashes are part of the public input.
Note that by including the ocapHashes in the public input some metadata is leaked. An observer will be able to see which capabilities have been frequently used, but they won’t learn anything about the content of the capability. A possible improvement could be to include the entire revocation registry stream in the public input. An outside observer could simply compare the included registry with a registry loaded locally. As long as the action performed by the invoker was anchored before any change to the registry was anchored the entire capability chain could still be considered valid. This means of course that the ZKP would need to be regenerated every time the revocation registry is updated.
3IDv2: Composable Identity
Combining the primitives above, we can create a composable DID method where verification methods can be added privately using capabilities and revoked using the revocation registry. Similarly to 3IDv0 the first verification method would be added to the initial (public) state of the DID document. After this point, adding a verification method is actually done by using a capability that gives the delegate DID full control to take any action on behalf of the 3ID.
A cool feature is that the revocation registry includes timestamp information (from Ceramic anchors) so that we can see when revocations happen. This makes it easy to validate signatures done in the past, since we can see that the signature was done by the verification method before it was revoked. However, in the case of a delegation chain this might not be enough. Imagine we have Key A delegating to Key B at time T0; then at time T1, Key A gets revoked. From the perspective of the 3ID, Key B should still be valid since it was added by a verification method that was valid at the time. In order to solve this dilemma we can simply add a timestamp for when Key B was added in the revocation registry. This would allow us to keep using Key B even if Key A is revoked.
There is a need to publicly or privately link existing DIDs, such as PKH DIDs, to a 3ID. This can now be done rather simply in Ceramic by creating a deterministic stream that is controlled by the DID in question and include an attestation that this DID is grafted to a particular 3ID. The attestation could be public or encrypted. If it is encrypted it could be selectively disclosed to any third party. For example, to check if an ethereum address (PKH DID) has been grafted to a 3ID, one would simply load the Ceramic stream and check the content of the resolved document. For a private link, a decryption key for the encrypted link would also be required to read to which DID the link points to.
An implementation of 3IDv2 might seem daunting at first. It’s worth noting that it can be done in stages, since each primitive builds upon the previous ones. A prototype could easily be built with only support for capabilities for adding new verification methods. The revocation registry could be built after adding the ability to actually do proper key management and would result in a usable version of the system. Finally, private capability invocation could be added on top, providing a layer of privacy when actually using the identity.