A did method that uses at uri's to resolve a did document in a pds
did-method-at SPEC.md
13 kB · 284 lines
Markdown
at main

The did:at DID Method #

Status: Draft Version: 0.3

1. Overview #

did:at is a DID method in which the method-specific identifier is an AT-URI pointing at a record — in an atproto repository, or in a space (per the atproto Spaces proposal). Resolving the DID means fetching that record and returning the DID document it contains.

The identity is therefore controlled by whoever can write the record: creating, rotating, and deactivating the DID are ordinary record writes, signed and replayable like any other record.

Because the authority of an AT-URI may itself be a DID, and did:at is a DID, identifiers can nest:

did:at:did:at:did:plc:23423492..at.did.doc..1234..at.did.doc..6789

is valid: the inner record (at://did:plc:23423492/at.did.doc/1234) holds a DID document, whose repo holds the outer record, and so on to any depth. Resolvers MUST therefore bound resolution with a recursion parameter (§7).

2. Method Name #

The method name is at. A DID that uses this method MUST begin with did:at:.

3. Method-Specific Identifier #

The method-specific identifier is an AT-URI with the at:// scheme prefix removed and each / path separator replaced by .. (DID syntax forbids / in identifiers but permits .):

at://<authority>/<p1>/<p2>/…/<pn>   ⇔   did:at:<authority>..<p1>..<p2>..…..<pn>

3.1 Design note: why .. and not : or escaping #

  • : separators collide with the colons inside the authority (every DID contains them), forcing arity heuristics and restricting authorities to single-segment DID methods. Variable-length paths (spaces, §3.3) make that unworkable.
  • Percent-escaping (did%3Aplc%3A…) is unambiguous but unreadable, and nested did:at identifiers double-encode at every level (%3A → %253A → %25253A).
  • .. can never occur inside a handle, DID, or NSID (domain labels and NSID segments cannot be empty), so components keep their natural colons — any DID method fits in one component, including did:web with ports or paths. The only cost is a small addressability restriction (§3.5).

3.2 Grammar #

Splitting the method-specific identifier on .. yields a component list; the grammar below is over that list. It is recursive: a nested did:at consumes its own path components.

msid       = did-or-handle path
path       = collection rkey                                      ; repo record
           / "space" stype skey did-or-handle rtype rkey          ; space record
did-or-handle = did-at-prefixed / did / handle    ; one component, except that a
                                                  ; did:at also consumes a path
collection = nsid          ; e.g. "at.did.doc"
stype      = nsid          ; space type
rtype      = nsid          ; record type within the space
skey       = record-key    ; the space's key
rkey       = record-key

A space path addresses at://sdid/space/stype/skey/did/rtype/rkey: the record of type rtype with key rkey, authored by member did, in the space stype/skey hosted by authority sdid. The literal component space is what distinguishes the two path forms — it can never be a collection, since space is not a valid NSID.

3.3 Parsing #

Parsing is left-to-right recursive descent over the component list. It is deterministic: the arity of a path is decided by the space literal, and the nesting depth of a DID is carried entirely by did:at: prefixes inside a single component.

  1. Strip did:at: and split the remainder on ... No component may be empty or begin or end with ..
  2. parseDid at cursor i: let c be component i.
    • If c begins with did:at: — strip that prefix and parseDid the remainder of c (its authority chain lives entirely within this component), then parse the nested DID's path (step 3) from the following components. The components of an embedded did:at flatten into the list innermost-first.
    • Otherwise c is a complete DID (any method) or a valid atproto handle; consume one component.
  3. parsePath at cursor i:
    • If component i is the literal space: consume space, stype (NSID), skey (record key), then parseDid (the member — which may itself be a did:at), then rtype (NSID) and rkey.
    • Otherwise: consume collection (NSID) and rkey.
  4. The identifier is parseDid followed by parsePath, and MUST consume the component list exactly.

3.4 Examples #

DID AT-URI it denotes
did:at:did:plc:23423492..at.did.doc..1234 at://did:plc:23423492/at.did.doc/1234
did:at:alice.example.com..at.did.doc..self at://alice.example.com/at.did.doc/self
did:at:did:at:did:plc:23423492..at.did.doc..1234..at.did.doc..6789 at://did:at:did:plc:23423492..at.did.doc..1234/at.did.doc/6789 (nested authority)
did:at:did:plc:23423492..space..com.example.room..3jabc..did:plc:99887..at.did.doc..self at://did:plc:23423492/space/com.example.room/3jabc/did:plc:99887/at.did.doc/self (space record)

(A did:at written inside an AT-URI keeps its .. separators, as in the third row — the authority position of an AT-URI cannot contain / either.)

3.5 Character restrictions #

DID syntax limits identifier characters to A–Z a–z 0–9 . - _ % plus :. Consequently:

  • Components keep literal colons, so rkeys containing : and did:web authorities with ports or paths are all addressable.
  • A record key containing ~, containing .., or beginning or ending with . cannot be addressed by this method. (~ is outside the DID character set; the dot forms would corrupt the component split.) Handles, DIDs, and NSIDs cannot violate these rules.

4. The DID Document Record #

The conventional collection is at.did.document. A record in it looks like:

{
  "$type": "at.did.document",
  "didDocument": {
    "@context": ["https://www.w3.org/ns/did/v1"],
    "id": "did:at:alice.example.com..at.did.document..self",
    "alsoKnownAs": ["at://did:plc:44ybard66vv44zksje25o7dz"],
    "verificationMethod": [
      {
        "id": "did:at:alice.example.com..at.did.document..self#key-1",
        "type": "Multikey",
        "controller": "did:at:alice.example.com..at.did.document..self",
        "publicKeyMultibase": "zQ3sh..."
      }
    ],
    "service": [
      {
        "id": "#atproto_pds",
        "type": "AtprotoPersonalDataServer",
        "serviceEndpoint": "https://pds.example.com"
      }
    ]
  },
  "deactivated": false
}

A lexicon schema is provided in lexicons/at.did.document.json. Any collection (or space record type) may be used, but the record value MUST contain a didDocument object.

4.1 Document requirements #

  • didDocument.id MUST be byte-for-byte equal to the did:at DID being resolved. This binds the document to its identifier: since the identifier encodes the record's location, only a writer to that location can publish a document for it.
  • If the DID is intended to be usable as the authority or member of a nested did:at, the document MUST additionally provide:
    • an alsoKnownAs entry of the form at://<did> naming the atproto repo that holds (or authors) the nested records, and
    • a service entry with id #atproto_pds giving that repo's PDS endpoint (optional if the resolver can resolve the alsoKnownAs DID normally).

5. Operations #

DID operation atproto operation
Create com.atproto.repo.createRecord / putRecord writing the record (space records: the corresponding Spaces write endpoint)
Read (Resolve) §7
Update com.atproto.repo.putRecord replacing the record
Deactivate com.atproto.repo.deleteRecord, or setting "deactivated": true

All writes are authorized by the repo owner's (or space member's) atproto credentials; no method-specific keys are involved in the control plane.

6. Resolving DIDs Along the Path #

Resolution must turn the authority — and, for space paths, the member DID — into concrete atproto identities:

  • handle — resolve per the atproto handle-resolution spec to a DID, then proceed as below.
  • did:plc / did:web — resolve per the atproto DID spec; the repo DID is the identifier itself and the PDS URL is the #atproto_pds service endpoint.
  • did:at — recursively resolve it (this consumes recursion budget, §7). From the resulting DID document take the first alsoKnownAs entry of the form at://<did> as the repo DID, and the #atproto_pds service endpoint as the PDS URL (falling back to normal resolution of the repo DID if the service entry is absent). If the document has no at:// alias, resolution fails with invalidDidDocument.
  • Any other DID method: resolution fails with unsupportedAuthority.

7. Resolution Algorithm #

Resolvers MUST accept a recursion parameter, maxRecursion, giving the maximum number of did:at resolutions a single request may perform (the outermost resolution counts as one; nested authorities and nested member DIDs each count as one). The budget is a single shared counter for the whole request — sibling resolutions (a space's authority and its member) draw from the same budget, so it bounds total fan-out, not just nesting depth. The RECOMMENDED default is 8. Resolvers SHOULD also track the set of did:at DIDs already being resolved on the current chain, to fail fast on cycles.

resolve(did, maxRecursion = 8):
  budget ← maxRecursion ; seen ← ∅ ; return resolveOne(did)

resolveOne(did):                     # every did:at on the chain re-enters here,
                                     # sharing budget and seen
  1. if budget = 0                  → error recursionLimitExceeded
     budget ← budget − 1
  2. if did ∈ seen                  → error cycleDetected
     seen ← seen ∪ {did}
  3. (authority, path) ← parse(did)                      # §3.3, error invalidDid
  4. (repoDid, pdsUrl) ← resolveDid(authority)           # §6; a did:at re-enters
                                                         # resolveOne
  5. fetch the record:
     a. repo path (collection, rkey):
          GET {pdsUrl}/xrpc/com.atproto.repo.getRecord
              ?repo={repoDid}&collection={collection}&rkey={rkey}
     b. space path (stype, skey, member, rtype, rkey):
          memberDid ← resolveDid(member)                 # §6; did:at re-enters
                                                         # resolveOne
          GET {pdsUrl}/xrpc/com.atproto.space.getRecord
              ?space=at://{repoDid}/space/{stype}/{skey}
              &repo={memberDid}&collection={rtype}&rkey={rkey}
          (endpoint name provisional — this method binds to whatever
           record-fetch endpoint the Spaces proposal standardizes on)
     on 404                         → error notFound
  6. if record.value.deactivated    → error deactivated (return doc + metadata)
  7. doc ← record.value.didDocument
     if doc missing or doc.id ≠ did → error invalidDidDocument
  8. return { didDocument: doc,
              didDocumentMetadata:   { deactivated: false },
              didResolutionMetadata: { recursionRemaining: budget } }

8. Security and Privacy Considerations #

  • Control = write access. Anyone who can write the record (repo owner, their PDS operator acting maliciously, a leaked app password; for space records, the authoring member and the space host) controls the DID. The trust root is the authority's identity system (did:plc's rotation keys, did:web's TLS, handle DNS).
  • Space records add the space host to the trust chain. The host serves the member's record; a malicious host can withhold or roll it back (substitution is still bounded by the §4.1 id check).
  • Recursion bombs and cycles. Nested identifiers let a short DID trigger unbounded network fan-out; maxRecursion and cycle detection are mandatory, not optional, for a conforming resolver.
  • The id equality check (§4.1) is load-bearing. Without it, a repo could serve a document claiming to be some other DID.
  • Mutability without history. Unlike did:plc, there is no audit log of prior documents at the resolver's disposal beyond what the firehose / repo history retains. Verifiers needing key-rotation history should not use this method alone.
  • Availability. Resolution depends on the liveness of every PDS, space host, and identity service along the chain; each nesting level multiplies failure modes.

9. Limitations (v0.3) #

  • Record keys containing ~ or .., or beginning/ending with ., are unaddressable (§3.5).
  • Handle authorities inherit handle-reassignment risk; prefer DID authorities for long-lived identifiers.
  • The space fetch endpoint (§7.5b) tracks a proposal that has not stabilized.
  • No verifiable document history.