Public Ledger of Credentials: a cryptographic, strongly-consistent, and recoverable DID method
DID PLC is a self-authenticating DID which is strongly-consistent, recoverable, and allows for key rotation.
An example DID is: did:plc:ewvi7nxzyoun6zhxrhs64oiz
Control over a did:plc
identity rests in a set of reconfigurable rotation keys pairs. These keys can sign update operations to mutate the identity (including key rotations), with each operation referencing a prior version of the identity state by hash. Each identity starts from an initial genesis operation, and the hash of this initial object is what defines the DID itself (that is, the DID URI identifier string). A central directory server collects and validates operations, and maintains a transparent log of operations for each DID.
This git repository contains a TypeScript reference implementation of the method (@did-plc/lib
) and a directory server @did-plc/server
, both in the package/
directory. The go-didplc/
directory is intended to hold a golang implementation.
Bluesky Social PBC developed DID PLC when designing the AT Protocol (atproto) because we were not satisfied with any of the existing DID methods. We wanted a strongly consistent, highly available, recoverable, and cryptographically secure method with fast and cheap propagation of updates.
PLC stands for "Public Ledger of Credentials". We expect to evolve the system (in a backwards-compatible manner) into something less centralized - likely a permissioned DID consortium. That being said, we do intend to support did:plc
in the current form until after any successor is deployed, with a reasonable grace period. We would also provide a migration route to allow continued use of existing did:plc
identifiers.
The core data fields associated with an active did:plc
identifier at any point in time are listed below. The encoding and structure differs somewhat from DID document formatting and semantics, but this information is sufficient to render a valid DID document.
did
(string): the full DID identifierrotationKeys
(array of strings): priority-ordered list of public keys in did:key
encoding. must include least 1 key and at most 5 keys, with no duplication. control of the DID identifier rests in these keys. not included in DID document.verificationMethods
(map with string keys and values): a set of service / public key mappings. the values are public keys did:key
encoding; they get re-encoded in "multibase" form when rendered in DID document. the key strings should not include a #
prefix; that will be added when rendering the DID document. used to generate verificationMethods
of DID document. these keys do not have control over the DID documentalsoKnownAs
(array of strings): priority-ordered list of URIs which indicate other names or aliases associated with the DID identifierservices
(map with string keys; values are maps with type
and endpoint
string fields): a set of service / URL mappings. the key strings should not include a #
prefix; that will be added when rendering the DID document.Every update operation to the DID identifier, including the initial creation operation (the genesis operation), contains all of the above information, except for the did
field. The DID itself is generated from a hash of the signed genesis operation (details described below), which makes the DID entirely self-certifying. Updates after initial creation contain a pointer to the most-recent previous operation (by hash).
Operations are signed and submitted to the central PLC directory server over an un-authenticated HTTP request. The PLC server validates operations against any and all existing operations on the DID (including signature validation, recovery time windows, etc), and either rejects the operation or accepts and permanently stores the operation, along with a server-generated timestamp.
A special operation type is a "tombstone", which clears all of the data fields and permanently deactivates the DID. Note that the usual recovery time window applies to tombstone operations.
Note that rotationKeys
and verificationMethods
(signing keys) may have public keys which are re-used across many accounts. There is not necessarily a one-to-one mapping between a DID and either rotation keys or signing keys.
Only secp256k1
("k256") and NIST P-256 ("p256") keys are currently supported, for both rotation and signing keys.
The following information should be included for use with atproto:
verificationMethods
: an atproto
entry with a "blessed" public key type, to be used as a signing key for authenticating updates to the account's repository. The signing key does not have any control over the DID identity unless also included in the rotationKeys
list. Best practice is to maintain separation between rotation keys and atproto signing keys.alsoKnownAs
: should include an at://
URI indicating a handle (hostname) for the account. Note that the handle/DID mapping needs to be validated bi-directionally (via handle resolution), and needs to be re-verified periodicallyservices
: an atproto_pds
entry with an AtprotoPersonalDataServer
type and http/https URL endpoint
indicating the account's current PDS hostname. for example, https://pds.example.com
(no /xrpc/
suffix needed).There are a couple of variations on the operation data object schema. The operations are also serialized both as simple JSON objects, or binary DAG-CBOR encoding for the purpose of hashing or signing.
A regular creation or update operation contains the following fields:
type
(string): with fixed value plc_operation
rotationKeys
(array of strings): as described aboveverificationMethods
(mapping of string keys and values): as described abovealsoKnownAs
(array of strings): as described aboveservices
(mapping of string keys and object values): as described aboveprev
(string, nullable): a CID hash pointer to a previous operation if an update, or null
for a creation. If null
, the key should actually be part of the object, with value null
, not simply omitted. In DAG-CBOR encoding, the CID is string-encoded, not a binary IPLD "Link"sig
(string): signature of the operation in base64url
encodingA tombstone operation contains:
type
(string): with fixed value plc_tombstone
prev
(string): same as above, but not nullablesig
(string): signature of the operation (same as above)There is also a deprecated legacy operation format, supported only for creation ("genesis") operations:
type
(string): with fixed value create
signingKey
(string): single did:key
value (not an array of strings)recoveryKey
(string): single did:key
value (not an array of strings); and note "recovery" terminology, not "rotation"handle
(string): single value, indicating atproto handle, instead of alsoKnownAs
. bare handle, with no at://
prefixservice
(string): single value, http/https URL of atproto PDSprev
(null): always include, but always with value null
sig
(string): signature of the operation (same as above)Legacy create
operations are stored in the PLC registry and may be returned in responses, so validating software needs to support that format. Conversion of the legacy format to "regular" operation format is relatively straight-forward, but there exist many did:plc
identifiers where the DID identifier itself is based on the hash of the old format, so they will unfortunately be around forever.
The process for signing and hashing operation objects is to first encode them in the DAG-CBOR binary serialization format. DAG-CBOR is a restricted subset of the Concise Binary Object Representation (CBOR), an IETF standard (RFC 8949), with semantics and value types similar to JSON.
As an anti-abuse mechanism, operations have a maximum size when encoded as DAG-CBOR. The current limit is 7500 bytes.
For signatures, the object is first encoded as DAG-CBOR without the sig
field at all (as opposed to a null
value in that field). Those bytes are signed, and then the signature bytes are encoded as a string using base64url
encoding. The sig
value is then populated with the string. In strongly typed programming languages it is a best practice to have distinct "signed" and "unsigned" types.
When working with signatures, note that ECDSA signatures are not necessarily deterministic or unique. That is, the same key signing the same bytes might generate the same signature every time, or it might generate a different signature every time, depending on the cryptographic library and configuration. In some cases it is also easy for a third party to take a valid signature and transform it into a new, distinct signature, which also validates. Be sure to always use the "validate signature" routine from a cryptographic library, instead of re-signing bytes and directly comparing the signature bytes.
For prev
references, the SHA-256 of the previous operation's bytes are encoded as a "CID", with the following parameters:
base32
multibase encoding (prefix: b
)dag-cbor
multibase type (code: 0x71)sha-256
multihash (code: 0x12)Rotation keys are serialized as strings using did:key, and only secp256k1
("k256") and NIST P-256 ("p256") are currently supported.
The signing keys (verificationMethods
) are also serialized using did:key
in operations. When rendered in a DID document, signing keys are represented as objects, with the actual keys in multibase encoding, as required by the DID Core specification.
The DID itself is derived from the hash of the first operation in the log, called the "genesis" operation. The signed operation is encoded in DAG-CBOR; the bytes are hashed with SHA-256; the hash bytes are base32
-encoded (not hex encoded) as a string; and that string is truncated to 24 chars to yield the "identifier" segment of the DID.
In pseudo-code:
did:plc:${base32Encode(sha256(createOp)).slice(0,24)}
The DID PLC method name is plc
. The identifier part is 24 characters
long, including only characters from the base32
encoding set. An example is
did:plc:yk4dd2qkboz2yv6tpubpc6co
. This means:
a-z
, 0-9
, and :
(and does not even use digits 0189
)Any key specified in rotationKeys
has the ability to sign operations for the DID document.
The set of rotation keys for a DID is not included in the DID document. They are an internal detail of PLC, and are stored in the operation log.
Keys are listed in the rotationKeys
field of operations in order of descending authority.
The PLC server provides a 72hr window during which a higher authority rotation key can "rewrite" history, clobbering any operations (or chain of operations) signed by a lower-authority rotation key.
To do so, that key must sign a new operation that points to the CID of the last "valid" operation - ie the fork point. The PLC server will accept this recovery operation as long as:
rotationKeys
array than the key that signed the to-be-invalidated operationThe full history of DID operations and updates, including timestamps, is permanently publicly accessible. This is true even after DID deactivation. It is important to recognize (and communicate to account holders) that any personally identifiable information (PII) encoded in alsoKnownAs
URIs will be publicly visible even after DID deactivation, and can not be redacted or purged.
In the context of atproto, this includes the full history of handle updates and PDS locations (URLs) over time. To be explicit, it does not include any other account metadata such as email addresses or IP addresses. Handle history could potentially de-anonymize account holders if they switch handles between a known identity and an anonymous or pseudonymous identity.
The PLC server does not cross-validate alsoKnownAs
or service
entries in operations. This means that any DID can "claim" to have any identity, or to have an active account with any service (identified by URL). This data should not be trusted without bi-directionally verification, for example using handle resolution.
The timestamp metadata encoded in the PLC audit log could be cross-verified against network traffic or other information to de-anonymize account holders. It also makes the "identity creation date" public.
If "rotation" and "signing" keys are re-used across multiple accounts, it could reveal non-public identity details or relationships. For example, if two individuals cross-share rotation keys as a trusted backup, that information is public. If device-local recovery or signing keys are uniquely shared by two identifiers, that would indicate that those identities may actually be the same person.
The PLC server has a public endpoint to receive operation objects from any client (without authentication). The server verifies operations, orders them according to recovery rules, and makes the log of operations publicly available.
The operation log is self-certifying, and contains all the information needed to construct (or verify) the the current state of the DID document.
Some trust is required in the PLC server. Its attacks are limited to:
To summarize the process of creating a new did:plc
identifier:
prev
field with null
value. do not use the deprecated/legacy operation format for new DID creationsrotationKeys
. encode the signature as base64url
, and use that to construct a "signed" operation objectbase32
. use the first 24 characters to generate DID value (did:plc:<hashchars>
)https://plc.directory/:did
When "signing" using a "rotationKey
", what is meant is to sign using the private key associated the public key in the rotationKey
list.
To summarize the process of updating a new did:plc
identifier:
https://plc.directory/:did/data
https://plc.directory/:did/log/audit
, identify the most recent valid operation, and get the cid
value. if this is a recovery operation, the relevant "valid" operation to fork from may not be the most recent in the audit logprev
field with the CID (hash) of the previous valid operationrotationKeys
. encode the signature as base64url
, and use that to construct a "signed" operation objecthttps://plc.directory/:did
To summarize the process of de-activating an existing did:plc
identifier:
https://plc.directory/:did/log/audit
, identify the most recent valid operation, and get the cid
valueprev
field with the CID (hash) of the previous valid operationrotationKeys
. encode the signature as base64url
, and use that to construct a "signed" tombstone operation objecthttps://plc.directory/:did
PLC DIDs are resolved to a DID document (JSON) by making simple HTTP GET request to the PLC server. The resolution endpoint is: https://plc.directory/:did
The PLC-specific state data (based on the most recent operation) can be fetched as a JSON object at: https://plc.directory/:did/data
As an additional check against abuse by the PLC server, and to promote resiliency, the set of all identifiers is enumerable, and the set of all operations for all identifiers (even "nullified" operations) can be enumerated and audited.
The log of currently-valid operations for a given DID, as JSON, can be found at: https://plc.directory/:did/log/audit
The audit history of a given DID (complete with timestamps and invalidated forked histories), as JSON, can be found at: https://plc.directory/:did/log/audit
To fully validate a DID document against the operation log:
rotationKeys
at that point of time: either the initial keys for a "genesis" operation, or the keys in the prev
operationsig
field and serialize the "unsigned" operation with DAG-CBOR, yielding bytesbase64url
sig
field to bytesrotationKeys
, attempt to verify the signature against the "unsigned" bytesThe complete log of operations for all DIDs on the PLC server can be enumerated efficiently:
https://plc.directory/export
count
query parameter, as an integer, maximum 1000 lines per requestafter
query parameter, based on createdAt
timestamp, for pagination// note: we use shorthand for keys for ease of reference, but consider them valid did:keys
// Genesis operation
const genesisOp = {
type: 'plc_operation',
verificationMethods: {
atproto: "did:key:zSigningKey"
},
rotationKeys: [
"did:key:zRecoveryKey",
"did:key:zRotationKey"
],
alsoKnownAs: [
"at://alice.test"
],
services: {
atproto_pds: {
type: "AtprotoPersonalDataServer",
endpoint: "https://example.test"
}
},
prev: null,
sig: 'sig_from_did:key:zRotationKey'
}
// Operation to update recovery key
const updateKeys = {
type: 'plc_operation',
verificationMethods: {
atproto: "did:key:zSigningKey"
},
rotationKeys: [
"did:key:zNewRecoveryKey",
"did:key:zRotationKey"
],
alsoKnownAs: [
"at://alice.test"
],
services: {
atproto_pds: {
type: "AtprotoPersonalDataServer",
endpoint: "https://example.test"
}
},
prev: CID(genesisOp),
sig: 'sig_from_did:key:zRotationKey'
}
// Invalid operation that will be rejected
// because did:key:zAttackerKey is not listed in rotationKeys
const invalidUpdate = {
type: 'plc_operation',
verificationMethods: {
atproto: "did:key:zAttackerKey"
},
rotationKeys: [
"did:key:zAttackerKey"
],
alsoKnownAs: [
"at://bob.test"
],
services: {
atproto_pds: {
type: "AtprotoPersonalDataServer",
endpoint: "https://example.test"
}
},
prev: CID(updateKeys),
sig: 'sig_from_did:key:zAttackerKey'
}
// Valid recovery operation that "undoes" updateKeys
const recoveryOp = {
type: 'plc_operation',
verificationMethods: {
atproto: "did:key:zSigningKey"
},
rotationKeys: [
"did:key:zRecoveryKey"
],
alsoKnownAs: [
"at://alice.test"
],
services: {
atproto_pds: {
type: "AtprotoPersonalDataServer",
endpoint: "https://example.test"
}
},
prev: CID(genesisOp),
sig: 'sig_from_did:key:zRecoveryKey'
}
The following data:
{
did: 'did:plc:7iza6de2dwap2sbkpav7c6c6',
verificationMethods: {
atproto: 'did:key:zDnaeh9v2RmcMo13Du2d6pjUf5bZwtauYxj3n9dYjw4EZUAR7'
},
rotationKeys: [
'did:key:zDnaedvvAsDE6H3BDdBejpx9ve2Tz95cymyCAKF66JbyMh1Lt',
'did:key:zDnaeh9v2RmcMo13Du2d6pjUf5bZwtauYxj3n9dYjw4EZUAR7'
],
alsoKnownAs: [
'at://alice.test'
],
services: {
atproto_pds: {
type: "AtprotoPersonalDataServer",
endpoint: "https://example.test"
}
}
}
Will be presented as the following DID document:
{
'@context': [
'https://www.w3.org/ns/did/v1',
'https://w3id.org/security/multikey/v1',
'https://w3id.org/security/suites/ecdsa-2019/v1'
],
id: 'did:plc:7iza6de2dwap2sbkpav7c6c6',
alsoKnownAs: [ 'at://alice.test' ],
verificationMethod: [
{
id: '#atproto',
type: 'Multikey',
controller: 'did:plc:7iza6de2dwap2sbkpav7c6c6',
publicKeyMultibase: 'zDnaeh9v2RmcMo13Du2d6pjUf5bZwtauYxj3n9dYjw4EZUAR7'
}
],
service: [
{
id: '#atproto_pds',
type: 'AtprotoPersonalDataServer',
serviceEndpoint: 'https://example2.com'
}
]
}
The set of allowed ("blessed") public key cryptographic algorithms (aka, curves) may expanded over time, slowly. Likewise, support for additional blessed CID types and parameters may be expanded over time, slowly.
The recovery time window may become configurable, within constraints, as part of the DID metadata itself.
Support for "DID Controller Delegation" could be useful (eg, in the context of atproto PDS hosts), and may be incorporated.
In the context of atproto, support for multiple handles for the same DID is being considered, with a single primary handle. But no final decision has been made yet.
We welcome proposals for small additions to make did:plc
more generic and reusable for applications other than atproto. But no promises: atproto will remain the focus for the near future.
We are enthusiastic about the prospect of moving governance of the did:plc
method, and operation of registry servers, out of the sole control of Bluesky Social PBC. Audit log snapshots, mirroring, and automated third-party auditing have all been considered as mechanisms to mitigate the centralized nature of the PLC server.
The size of the verificationMethods
, alsoKnownAs
, and service
mappings/arrays may be specifically constrained. And the maximum DAG-CBOR size may be constrained.
As an anti-abuse mechanisms, the PLC server load balancer restricts the number of HTTP requests per time window. The limits are generous, and operating large services or scraping the operation log should not run into limits. Specific per-DID limits on operation rate may be introduced over time. For example, no more than N operations per DID per rotation key per 24 hour window.
A "DID PLC history explorer" web interface would make the public nature of the DID audit log more publicly understandable.
It is conceivable that longer DID PLCs, with more of the SHA-256 characters, will be supported in the future. It is also conceivable that a different hash algorithm would be allowed. Any such changes would allow existing DIDs in their existing syntax to continue being used.
This project is dual-licensed under MIT and Apache 2.0 terms:
Downstream projects and users may chose either license, or both, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.
a collection of lightweight TypeScript packages for AT Protocol, the protocol powering Bluesky.
Use this repository to get started with your own Bluesky Labeler.
ATProto Feed Generator Starter Kit
AT Protocol Reference Implementation (TypeScript)
A dead simple client for subscribing to an ATProto Relay ("firehose").
A fully typed client for the Bluesky Jetstream (https://github.com/bluesky-social/jetstream) service.
Your Brand Here!
50K+ engaged viewers every month
Limited spots available!
📧 Contact us via email🦋 Contact us on Bluesky