Creating a did:web atproto account using goat

@bnewbold.net

This is a hastily-written guide to creating a did:web atproto account.

You'll need:

  • familiarity with command line tools
  • a domain name and web server you control, for the did:web
  • a handle domain name you control (can be the same as the did:web domain)
  • an invite code to an atproto PDS
  • the goat tool installed (recent version)

NOTE: Until recently, goat had a bug with deactivated accounts. This is fixed on current "main" branch, and should be included in v0.2.2+.

Prepare Identity (did:web and handle)

Note that you'll be updating the DID document multiple times through this process, so you might want to keep a terminal window open.

Chose a domain (and thus DID identifier): did:web:did.example.com

Separately chose a handle: @handle.example.com

Note that the handle does not need to match the did:web domain name! This can cause confusion. The did:web domain is persistent (permanent for the lifetime of this account), but the handle can be updated.

Choose PDS instance: https://pds.example.com

Configure a DNS record for the handle (DNS can take a while to propagate, so good to start with this):

_atproto.handle.example.com. TXT did=did:web:did.example.com

More details on setting up DNS-based handles in the Bluesky app docs.

Create a temporary atproto signing key for this DID:

$ goat key generate

Key Type: P-256 / secp256r1 / ES256 private key
Secret Key (Multibase Syntax): save this securely (eg, add to password manager)
	z42tuKwWH56QXkEZ8JpfkSCkTXqbQSP7ZfrThywZ3o9qaZSW
Public Key (DID Key Syntax): share or publish this (eg, in DID document)
	did:key:zDnaeu6uTxiWcXxiQLftFPsiegCQEp7FimQ1ay33GZLSb9H55

You'll need the public key in "Multibase" format. You can either manually remove the did:key: prefix, or use goat key inspect <PUBKEY>. Be careful to not confuse the secret and public parts of the keypair!

Create a DID document starting with this example:

{
  "@context": [
    "https://www.w3.org/ns/did/v1",
    "https://w3id.org/security/multikey/v1",
    "https://w3id.org/security/suites/secp256k1-2019/v1"
  ],
  "id": "did:web:did.example.com",
  "alsoKnownAs": [
    "at://handle.example.com"
  ],
  "verificationMethod": [
    {
      "id": "did:web:did.example.com#atproto",
      "type": "Multikey",
      "controller": "did:web:did.example.com",
      "publicKeyMultibase": "zDnaedatVBJg5bEeXsx8uA9m2ord1UKphH98E4jvb7fJm1Qfb"
    }
  ],
  "service": [
    {
      "id": "#atproto_pds",
      "type": "AtprotoPersonalDataServer",
      "serviceEndpoint": "https://pds.example.com"
    }
  ]
}

Some key things to note when editing the template:

  • the top-level id and all other instances of id and controller get updated to the actual did:web value (eg, the string 'example.com' should not appear in the final document)
  • the PDS hostname gets updated in service, serviceEndpoint
  • the handle gets set in alsoKnownAs, with an at:// prefix
  • the temporary atproto public key is set under verificationMethod. You take the "Public Key" value generated by goat above, and remove the did:key: prefix
  • needs to be strictly valid JSON: no extra commas, etc

This needs to end up at https://did.example.com/.well-known/did.json, with an appropriate Content-Type header (eg, application/json). There are a couple ways to achieve this depending on your webserver setup. If the entire website is simple static hosting, you be able to create a directory .well-known/ and put a did.json file in that folder, and the webserver will set the correct content type. Or you might need to create a mapping for just the /.well-known/ path prefix to use static file hosting; or you might configure a fixed response body in the web server itself. Search around for how to setup a "well-known" file for the web server software you run (eg, nginx, haproxy, Apache, Caddy, etc).

Some browser-based tools may require CORS settings to function, but this is not strictly required by the protocol specifications. If you want to enable this, it will usually be in your web server configuration, and you can search around for instructions specific to your setup.

After waiting for DNS to propagate, confirm that things are working:

# starting from the DID
goat resolve did:web:did.example.com

# starting from the handle
goat resolve handle.example.com

Generate PDS Invite Code

If you administer your own PDS instance, you can generate a new invite code by shelling in to the PDS server (with ssh), and running:

docker exec pds goat pds admin create-invites

That will return a single code with a single use.

Create PDS Account

Gather your configuration into the following environment variables:

  • ACCOUNT_DID: your did:web
  • ACCOUNT_HANDLE: account handle that you configured
  • TEMP_ATPROTO_SECRET_KEY: the "Secret Key" part of the temporary keypair you created above
  • PDS_HOSTNAME: hostname of the PDS instance, without https:// prefix
  • INVITE_CODE: invite to new PDS instance, for creating account
  • ACCOUNT_PASSWORD: secure password for the PDS account
  • ACCOUNT_EMAIL: account email for PDS account

To create a PDS account with an existing DID, you need to prove control of that DID as part of the account signup process. You do this using a signed auth token, which you can generate using goat:

goat account service-auth-offline \
    --atproto-signing-key "$TEMP_ATPROTO_SECRET_KEY" \
    --lxm com.atproto.server.createAccount \
    --iss "$ACCOUNT_DID" \
    --aud "did:web:$PDS_HOSTNAME" \
    --duration-sec 3600

Save that value as SIGNED_TOKEN.

Now you can create the actual account on the PDS:

goat account create \
   --pds-host "https://$PDS_HOSTNAME" \
    --existing-did "$ACCOUNT_DID" \
    --handle "$ACCOUNT_HANDLE" \
    --password "$ACCOUNT_PASSWORD" \
    --email "$ACCOUNT_EMAIL" \
    --invite-code "$INVITE_CODE" \
    --service-auth "$SIGNED_TOKEN"

Assuming that worked, the account will be in "deactivated" state, because the PDS can not make signatures on behalf of the account. You will need to update the DID document with the PDS-controlled signing key before the account can be used.

You should be able to log in using goat:

goat account login \
    --pds-host "https://$PDS_HOSTNAME" \
    -u "$ACCOUNT_DID" \
    -p "$ACCOUNT_PASSWORD"

NOTE: if you get an error here about inactive state, you may need to upgrade goat. If you only get a warning log line, you can ignore it.

Fetch the recommended DID parameters from the PDS (this requires an authenticated login). Note that the command and response mention "plc", but this API call is not actually did:plc-specific.

$ goat account plc recommended

{
  "alsoKnownAs": [
    "at://handle.example.com"
  ],
  "verificationMethods": {
    "atproto": "did:key:zQ3shr9kTLFhFCxFpmwSZ8bLb7gfekfdownKYufYBUp1iFcvp"
  },
  "rotationKeys": [
    "did:key:zQ3shcciz4AvrLyDnUdZLpQys3kyCsesojRNzJAieyDStGxGo"
  ],
  "services": {
    "atproto_pds": {
      "type": "AtprotoPersonalDataServer",
      "endpoint": "https://pds.example.com"
    }
  }
}

Take the verificationMethods, atproto value, remove the did:key: prefix, and insert it in to the did:web document:

  "verificationMethod": [
    { 
      "id": "did:web:did.example.com#atproto",
      "type": "Multikey",
      "controller": "did:web:did.example.com",
      "publicKeyMultibase": "zQ3shr9kTLFhFCxFpmwSZ8bLb7gfekfdownKYufYBUp1iFcvp"
    }
  ]

Confirm that your update worked by re-resolving the DID:

goat resolve did:web:did.example.com | jq .verificationMethod

You should now be able to activate the PDS account:

goat account activate

The account should now be functional in the network!

You can confirm account status on a popular relay instance:

goat relay account status --relay-host https://relay1.us-east.bsky.network did:web:did.example.com

Create a microblogging post and check if it shows up in apps:

goat bsky post "first test post from did:web account"

# check apps:
#   https://bsky.app/profile/did:web:did.example.com
#   https://staging.blacksky.community/profile/did:web:did.example.com

It is possible that some servers or services will have stale identity metadata cached for the account at this point. This can cause "invalid handle" errors, or break inter-service auth (eg, feed generation requests). Sometimes these will resolve after 24 hours. A better way to resolve this would be self-service PDS functionality to emit additional #identity events for the account, which would usually result in all downstream services in the network to reload their identity metadata within a few seconds.

bnewbold.net
bryan newbold

@bnewbold.net

oscilloscopes, cycling, snow, big cities, wiki. I like speculating about found objects.
protocol engineer @bsky.app. formerly archive.org
elsewhere: bnewbold.net / @bnewbold@social.coop

Post reaction in Bluesky

*To be shown as a reaction, include article link in the post or add link card

Reactions from everyone (0)