aboutsummaryrefslogtreecommitdiff
path: root/cmd/didweb
diff options
context:
space:
mode:
authorXe Iaso <me@xeiaso.net>2025-01-12 16:35:30 -0500
committerXe Iaso <me@xeiaso.net>2025-01-12 16:35:37 -0500
commitbc996c9e5bfe66c3651e4521feaeb68917145c47 (patch)
treee48ed19664ff8da898cc910d3a482104886491a6 /cmd/didweb
parent70e85fdb545a8c653690d5bf12c56ba9bccfd3bb (diff)
downloadx-bc996c9e5bfe66c3651e4521feaeb68917145c47.tar.xz
x-bc996c9e5bfe66c3651e4521feaeb68917145c47.zip
add cmd/didweb
Signed-off-by: Xe Iaso <me@xeiaso.net>
Diffstat (limited to 'cmd/didweb')
-rw-r--r--cmd/didweb/.gitignore3
-rw-r--r--cmd/didweb/README.md3
-rw-r--r--cmd/didweb/atproto-recommended-credentials.go52
-rw-r--r--cmd/didweb/did.go24
-rw-r--r--cmd/didweb/main.go359
5 files changed, 441 insertions, 0 deletions
diff --git a/cmd/didweb/.gitignore b/cmd/didweb/.gitignore
new file mode 100644
index 0000000..7fd6449
--- /dev/null
+++ b/cmd/didweb/.gitignore
@@ -0,0 +1,3 @@
+*.keys
+did.json
+atproto-did \ No newline at end of file
diff --git a/cmd/didweb/README.md b/cmd/didweb/README.md
new file mode 100644
index 0000000..779eb51
--- /dev/null
+++ b/cmd/didweb/README.md
@@ -0,0 +1,3 @@
+# didweb
+
+This is a heavily forked and modified version of [didweb](https://github.com/afternooncurry/bsky-did-web), but made to be much more understandable.
diff --git a/cmd/didweb/atproto-recommended-credentials.go b/cmd/didweb/atproto-recommended-credentials.go
new file mode 100644
index 0000000..2b870c0
--- /dev/null
+++ b/cmd/didweb/atproto-recommended-credentials.go
@@ -0,0 +1,52 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+
+ "github.com/bluesky-social/indigo/xrpc"
+ "github.com/whyrusleeping/go-did"
+ "within.website/x/web"
+)
+
+type RecommendedCredentials struct {
+ AlsoKnownAs []string `json:"alsoKnownAs"`
+ VerificationMethods struct {
+ Atproto did.DID `json:"atproto"`
+ } `json:"verificationMethods"`
+ RotationKeys []string `json:"rotationKeys"`
+ Services struct {
+ AtprotoPds struct {
+ Type string `json:"type"`
+ Endpoint string `json:"endpoint"`
+ } `json:"atproto_pds"`
+ } `json:"services"`
+}
+
+func IdentityGetRecommendedDidCredentials(ctx context.Context, cli *xrpc.Client) (*RecommendedCredentials, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, cli.Host+"/xrpc/com.atproto.identity.getRecommendedDidCredentials", nil)
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Add("Authorization", "Bearer "+cli.Auth.AccessJwt)
+ req.Header.Add("Accept", "application/json")
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, web.NewError(http.StatusOK, resp)
+ }
+
+ var result RecommendedCredentials
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return nil, err
+ }
+
+ return &result, nil
+}
diff --git a/cmd/didweb/did.go b/cmd/didweb/did.go
new file mode 100644
index 0000000..62f5a6a
--- /dev/null
+++ b/cmd/didweb/did.go
@@ -0,0 +1,24 @@
+package main
+
+import "github.com/whyrusleeping/go-did"
+
+type Document struct {
+ Context []string `json:"@context"`
+ Id did.DID `json:"id"`
+ AlsoKnownAs []string `json:"alsoKnownAs"`
+ VerificationMethod []*VerificationMethod `json:"verificationMethod"`
+ Service []*Service `json:"service"`
+}
+
+type VerificationMethod struct {
+ ID string `json:"id"`
+ Type string `json:"type"`
+ Controller string `json:"controller"`
+ PublicKeyMultibase string `json:"publicKeyMultibase"`
+}
+
+type Service struct {
+ ID did.DID `json:"id"`
+ Type string `json:"type"`
+ ServiceEndpoint string `json:"serviceEndpoint"`
+}
diff --git a/cmd/didweb/main.go b/cmd/didweb/main.go
new file mode 100644
index 0000000..8396df5
--- /dev/null
+++ b/cmd/didweb/main.go
@@ -0,0 +1,359 @@
+package main
+
+import (
+ "bufio"
+ "context"
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "log"
+ "log/slog"
+ "net/http"
+ "os"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/bluesky-social/indigo/api/atproto"
+ "github.com/bluesky-social/indigo/xrpc"
+ "github.com/golang-jwt/jwt/v5"
+ "github.com/google/uuid"
+ "github.com/multiformats/go-multibase"
+ "github.com/whyrusleeping/go-did"
+ "within.website/x/internal"
+ "within.website/x/web"
+)
+
+var (
+ curveName = flag.String("curve-name", "p256", "elliptic curve to use")
+ handleName = flag.String("handle-name", "", "The bluesky handle you want to use")
+ pdsHostname = flag.String("pds-hostname", "", "The Personal Data Server hostname for this account")
+)
+
+func main() {
+ internal.HandleStartup()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
+ defer cancel()
+
+ crv, ok := getCurve(*curveName)
+ if !ok {
+ log.Fatalf("invalid curve: %s", *curveName)
+ }
+
+ if *handleName == "" {
+ log.Fatal("pass --handle-name")
+ }
+
+ if *pdsHostname == "" {
+ log.Fatal("pass --pds-hostname (no scheme)")
+ }
+
+ privkey, err := generateKey(crv)
+ if err != nil {
+ log.Fatalf("can't make privkey: %v", err)
+ }
+
+ privkeyString := serializePrivateKey(privkey)
+
+ pubkeyString, err := serializePublicKey(crv, privkey)
+ if err != nil {
+ log.Fatalf("can't serialize public key: %v", err)
+ }
+
+ fmt.Printf("------\nFor did:web:%s:\npublic: %s\nprivate: %s\n", *handleName, pubkeyString, privkeyString)
+
+ handleDID, err := did.ParseDID("did:web:" + *handleName)
+ if err != nil {
+ log.Fatalf("can't parse your DID: %v", err)
+ }
+
+ pdsDID, err := did.ParseDID("did:web:" + *pdsHostname)
+ if err != nil {
+ log.Fatalf("can't parse PDS did: %v", err)
+ }
+
+ serviceDID, err := did.ParseDID("#atproto_pds")
+ if err != nil {
+ log.Fatalf("[unexpected] can't parse service DID: %v", err)
+ }
+
+ didDoc := Document{
+ Context: []string{
+ "https://www.w3.org/ns/did/v1",
+ "https://w3id.org/security/multikey/v1",
+ "https://w3id.org/security/suites/secp256k1-2019/v1",
+ },
+ Id: handleDID,
+ AlsoKnownAs: []string{"at://" + *handleName},
+ VerificationMethod: []*VerificationMethod{
+ {
+ ID: handleDID.String() + "#atproto",
+ Type: "Multikey",
+ Controller: handleDID.String(),
+ PublicKeyMultibase: pubkeyString,
+ },
+ },
+ Service: []*Service{
+ {
+ ID: serviceDID,
+ Type: "AtprotoPersonalDataServer",
+ ServiceEndpoint: "https://" + *pdsHostname,
+ },
+ },
+ }
+
+ fout, err := os.Create("did.json")
+ if err != nil {
+ log.Fatalf("can't make did.json: %v", err)
+ }
+
+ enc := json.NewEncoder(fout)
+ enc.SetIndent("", " ")
+ if err := enc.Encode(didDoc); err != nil {
+ fout.Close()
+ log.Fatalf("can't encode DID doc: %v", err)
+ }
+
+ if err := fout.Close(); err != nil {
+ log.Fatalf("can't close did.json: %v", err)
+ }
+
+ fmt.Printf("upload did.json to https://%[1]s/.well-known/did.json and press enter ...\n(hint: make sure %[1]s points to where you uploaded it to!)\n", *handleName)
+ bufio.NewReader(os.Stdin).ReadBytes('\n')
+
+ if err := waitUntilDIDWorks(ctx, *handleName, pubkeyString); err != nil {
+ log.Fatalf("%s doesn't work: %v", handleDID.String(), err)
+ }
+
+ fmt.Println("DID validated!")
+
+ if err := os.WriteFile("atproto-did", []byte(handleDID.String()), 0600); err != nil {
+ log.Fatalf("can't write atproto-did: %v", err)
+ }
+
+ fmt.Printf("upload atproto-did to https://%s/.well-known/atproto-did in it, then press enter\n", handleDID.Value())
+ bufio.NewReader(os.Stdin).ReadBytes('\n')
+
+ cli, err := mkAccount(ctx, privkey, &handleDID, &pdsDID)
+ if err != nil {
+ log.Fatalf("can't make account: %v", err)
+ }
+
+ credSuggestions, err := IdentityGetRecommendedDidCredentials(ctx, cli)
+ if err != nil {
+ fmt.Printf("access token: %s\n", cli.Auth.AccessJwt)
+ fmt.Println("You will need to do things manually, sorry")
+ log.Fatalf("can't get suggested credentials: %v", err)
+ }
+
+ // give the PDS authority over the identity
+ didDoc.VerificationMethod[0].PublicKeyMultibase = credSuggestions.VerificationMethods.Atproto.Value()
+
+ fout, err = os.Create("did.json")
+ if err != nil {
+ log.Fatalf("can't make did.json: %v", err)
+ }
+
+ enc = json.NewEncoder(fout)
+ enc.SetIndent("", " ")
+ if err := enc.Encode(didDoc); err != nil {
+ fout.Close()
+ log.Fatalf("can't encode DID doc: %v", err)
+ }
+
+ if err := fout.Close(); err != nil {
+ log.Fatalf("can't close did.json: %v", err)
+ }
+
+ fmt.Printf("re-upload did.json to https://%[1]s/.well-known/did.json and press enter ...\n", *handleName)
+ bufio.NewReader(os.Stdin).ReadBytes('\n')
+
+ if err := waitUntilDIDWorks(ctx, *handleName, credSuggestions.VerificationMethods.Atproto.Value()); err != nil {
+ log.Fatalf("%s doesn't work: %v", handleDID.String(), err)
+ }
+
+ if err := atproto.ServerActivateAccount(ctx, cli); err != nil {
+ log.Fatalf("can't activate account: %v", err)
+ }
+
+ fmt.Println("have fun skeeting!")
+}
+
+func getCurve(name string) (elliptic.Curve, bool) {
+ switch name {
+ case "p256":
+ return elliptic.P256(), true
+ default:
+ return nil, false
+ }
+}
+
+func generateKey(crv elliptic.Curve) (*ecdsa.PrivateKey, error) {
+ privkey, err := ecdsa.GenerateKey(crv, rand.Reader)
+ if err != nil {
+ return nil, err
+ }
+
+ return privkey, nil
+}
+
+func serializePrivateKey(privkey *ecdsa.PrivateKey) string {
+ return hex.EncodeToString(privkey.D.Bytes())
+}
+
+func serializePublicKey(crv elliptic.Curve, privkey *ecdsa.PrivateKey) (string, error) {
+ // varint encoded version of 0x1200, see https://atproto.com/specs/cryptography#public-key-encoding
+ var varintP256 = []byte{0x80, 0x24}
+
+ b := slices.Concat(varintP256, elliptic.MarshalCompressed(crv, privkey.PublicKey.X, privkey.PublicKey.Y))
+ pubkey, err := multibase.Encode(multibase.Base58BTC, b)
+ if err != nil {
+ return "", err
+ }
+
+ return pubkey, nil
+}
+
+func fetchDIDWeb(ctx context.Context, domain string) (*did.Document, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("https://%s/.well-known/did.json", domain), nil)
+ if err != nil {
+ return nil, fmt.Errorf("can't construct request for domain %s: %w", domain, err)
+ }
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("can't connect to domain %s: %w", domain, err)
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, web.NewError(http.StatusOK, resp)
+ }
+
+ var result did.Document
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return nil, fmt.Errorf("can't decode DID document on %s: %w", domain, err)
+ }
+
+ return &result, nil
+}
+
+func waitUntilDIDWorks(ctx context.Context, domain, wantPubkey string) error {
+ t := time.NewTicker(time.Second)
+ defer t.Stop()
+ errCount := 0
+
+ for range t.C {
+ if errCount >= 30 {
+ return fmt.Errorf("gave up after %d tries", errCount)
+ }
+
+ doc, err := fetchDIDWeb(ctx, domain)
+ if err != nil {
+ errCount++
+ slog.Error("can't load did doc", "domain", domain, "err", err)
+ continue
+ }
+
+ var multiKey *did.VerificationMethod
+ for _, vm := range doc.VerificationMethod {
+ if vm.Type == "Multikey" {
+ multiKey = &vm
+ }
+ }
+
+ if multiKey == nil {
+ return fmt.Errorf("invalid DID, need Multikey verification method")
+ }
+
+ if theirKey := *multiKey.PublicKeyMultibase; theirKey != wantPubkey {
+ return fmt.Errorf("wrong public key: want %s, got %s", wantPubkey, theirKey)
+ }
+
+ return nil
+ }
+
+ return fmt.Errorf("how did you get here?")
+}
+
+func makeJWTFor(privkey *ecdsa.PrivateKey, lxm, aud, iss string, exp time.Duration) (string, error) {
+ type myClaims struct {
+ jwt.RegisteredClaims
+ Lexicon string `json:"lxm"` // Atproto XRPC lexicon method for the scope of this JWT
+ }
+
+ jwt.MarshalSingleStringAsArray = false
+ claims := myClaims{
+ Lexicon: lxm,
+ RegisteredClaims: jwt.RegisteredClaims{
+ Issuer: iss,
+ Audience: jwt.ClaimStrings{aud},
+ ExpiresAt: jwt.NewNumericDate(time.Now().Add(exp)),
+ IssuedAt: jwt.NewNumericDate(time.Now()),
+ ID: uuid.NewString(),
+ },
+ }
+
+ token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
+ tokenString, err := token.SignedString(privkey)
+ if err != nil {
+ return "", err
+ }
+
+ return tokenString, nil
+}
+
+func mkAccount(ctx context.Context, privkey *ecdsa.PrivateKey, handle, pds *did.DID) (*xrpc.Client, error) {
+ didJWT, err := makeJWTFor(privkey, "com.atproto.server.createAccount", pds.String(), handle.String(), 180*time.Second)
+ if err != nil {
+ return nil, err
+ }
+
+ cli := &xrpc.Client{
+ Auth: &xrpc.AuthInfo{
+ AccessJwt: didJWT,
+ Handle: handle.Value(),
+ Did: handle.String(),
+ },
+ Host: "https://" + pds.Value(),
+ }
+
+ fmt.Printf("hint: generate an invite code with:\n$ sudo pdsadmin create-invite-code\n\n")
+
+ inviteCode := input("PDS invite code")
+ email := input("email")
+ password := input("password")
+
+ inp := &atproto.ServerCreateAccount_Input{
+ Did: &[]string{handle.String()}[0],
+ Email: &email,
+ Handle: handle.Value(),
+ InviteCode: &inviteCode,
+ Password: &password,
+ }
+
+ resp, err := atproto.ServerCreateAccount(ctx, cli, inp)
+ if err != nil {
+ return nil, err
+ }
+
+ cli.Auth.AccessJwt = resp.AccessJwt
+ cli.Auth.RefreshJwt = resp.RefreshJwt
+ cli.Auth.Did = resp.Did
+ cli.Auth.Handle = resp.Handle
+
+ return cli, nil
+}
+
+func input(prompt string) string {
+ fmt.Printf("%s> ", prompt)
+ text, err := bufio.NewReader(os.Stdin).ReadString('\n')
+ if err != nil {
+ panic(err)
+ }
+ return strings.TrimSpace(text)
+}