this repo has no description
13
fork

Configure Feed

Select the types of activity you want to include in your feed.

copy didplc work from unmerged did-method-plc branch

+1501
+212
cmd/plcli/main.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "io" 8 + "log/slog" 9 + "os" 10 + 11 + "github.com/bluesky-social/indigo/atproto/crypto" 12 + "github.com/bluesky-social/indigo/atproto/syntax" 13 + "tangled.sh/bnewbold.net/cobalt/didplc" 14 + 15 + "github.com/urfave/cli/v2" 16 + ) 17 + 18 + func main() { 19 + app := cli.App{ 20 + Name: "plcli", 21 + Usage: "simple CLI client tool for PLC operations", 22 + } 23 + app.Flags = []cli.Flag{ 24 + &cli.StringFlag{ 25 + Name: "plc-host", 26 + Usage: "method, hostname, and port of PLC registry", 27 + Value: "https://plc.directory", 28 + EnvVars: []string{"PLC_HOST"}, 29 + }, 30 + } 31 + app.Commands = []*cli.Command{ 32 + &cli.Command{ 33 + Name: "resolve", 34 + Usage: "resolve a DID from remote PLC directory", 35 + ArgsUsage: "<did>", 36 + Action: runResolve, 37 + }, 38 + &cli.Command{ 39 + Name: "submit", 40 + Usage: "submit a PLC operation (reads JSON from stdin)", 41 + ArgsUsage: "<did>", 42 + Action: runSubmit, 43 + Flags: []cli.Flag{ 44 + &cli.StringFlag{ 45 + Name: "plc-private-rotation-key", 46 + Usage: "private key used as a rotation key, if operation is not signed (multibase syntax)", 47 + EnvVars: []string{"PLC_PRIVATE_ROTATION_KEY"}, 48 + }, 49 + }, 50 + }, 51 + &cli.Command{ 52 + Name: "oplog", 53 + Usage: "fetch log of operations from PLC directory, for a single DID", 54 + ArgsUsage: "<did>", 55 + Action: runOpLog, 56 + Flags: []cli.Flag{ 57 + &cli.BoolFlag{ 58 + Name: "audit", 59 + Usage: "audit mode, with nullified entries included", 60 + }, 61 + }, 62 + }, 63 + &cli.Command{ 64 + Name: "verify", 65 + Usage: "fetch audit log for a DID, and verify all operations", 66 + ArgsUsage: "<did>", 67 + Action: runVerify, 68 + Flags: []cli.Flag{ 69 + &cli.BoolFlag{ 70 + Name: "audit", 71 + Usage: "audit mode, with nullified entries included", 72 + }, 73 + }, 74 + }, 75 + } 76 + h := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}) 77 + slog.SetDefault(slog.New(h)) 78 + app.RunAndExitOnError() 79 + } 80 + 81 + func runResolve(cctx *cli.Context) error { 82 + ctx := context.Background() 83 + s := cctx.Args().First() 84 + if s == "" { 85 + fmt.Println("need to provide DID as an argument") 86 + os.Exit(-1) 87 + } 88 + 89 + did, err := syntax.ParseDID(s) 90 + if err != nil { 91 + fmt.Println(err) 92 + os.Exit(-1) 93 + } 94 + 95 + c := didplc.Client{ 96 + DirectoryURL: cctx.String("plc-host"), 97 + } 98 + doc, err := c.Resolve(ctx, did.String()) 99 + if err != nil { 100 + return err 101 + } 102 + jsonBytes, err := json.Marshal(&doc) 103 + if err != nil { 104 + return err 105 + } 106 + fmt.Println(string(jsonBytes)) 107 + return nil 108 + } 109 + 110 + func runSubmit(cctx *cli.Context) error { 111 + ctx := context.Background() 112 + s := cctx.Args().First() 113 + if s == "" { 114 + fmt.Println("need to provide DID as an argument") 115 + os.Exit(-1) 116 + } 117 + 118 + did, err := syntax.ParseDID(s) 119 + if err != nil { 120 + return err 121 + } 122 + 123 + c := didplc.Client{ 124 + DirectoryURL: cctx.String("plc-host"), 125 + } 126 + 127 + inBytes, err := io.ReadAll(os.Stdin) 128 + if err != nil { 129 + return err 130 + } 131 + var enum didplc.OpEnum 132 + if err := json.Unmarshal(inBytes, &enum); err != nil { 133 + return err 134 + } 135 + op := enum.AsOperation() 136 + 137 + if !op.IsSigned() { 138 + privStr := cctx.String("plc-private-rotation-key") 139 + if privStr == "" { 140 + return fmt.Errorf("operation is not signed and no privte key provided") 141 + } 142 + priv, err := crypto.ParsePrivateMultibase(privStr) 143 + if err != nil { 144 + return err 145 + } 146 + if err := op.Sign(priv); err != nil { 147 + return err 148 + } 149 + } 150 + 151 + entry, err := c.Submit(ctx, did.String(), op) 152 + if err != nil { 153 + return err 154 + } 155 + jsonBytes, err := json.Marshal(&entry) 156 + if err != nil { 157 + return err 158 + } 159 + fmt.Println(string(jsonBytes)) 160 + return nil 161 + } 162 + 163 + func fetchOplog(cctx *cli.Context) ([]didplc.LogEntry, error) { 164 + ctx := context.Background() 165 + s := cctx.Args().First() 166 + if s == "" { 167 + return nil, fmt.Errorf("need to provide DID as an argument") 168 + } 169 + 170 + did, err := syntax.ParseDID(s) 171 + if err != nil { 172 + return nil, err 173 + } 174 + 175 + c := didplc.Client{ 176 + DirectoryURL: cctx.String("plc-host"), 177 + } 178 + entries, err := c.OpLog(ctx, did.String(), cctx.Bool("audit")) 179 + if err != nil { 180 + return nil, err 181 + } 182 + return entries, nil 183 + } 184 + 185 + func runOpLog(cctx *cli.Context) error { 186 + entries, err := fetchOplog(cctx) 187 + if err != nil { 188 + return err 189 + } 190 + 191 + jsonBytes, err := json.Marshal(&entries) 192 + if err != nil { 193 + return err 194 + } 195 + fmt.Println(string(jsonBytes)) 196 + return nil 197 + } 198 + 199 + func runVerify(cctx *cli.Context) error { 200 + entries, err := fetchOplog(cctx) 201 + if err != nil { 202 + return err 203 + } 204 + 205 + err = didplc.VerifyOpLog(entries) 206 + if err != nil { 207 + return err 208 + } 209 + 210 + fmt.Println("valid") 211 + return nil 212 + }
+155
didplc/client.go
··· 1 + package didplc 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "errors" 8 + "fmt" 9 + "io" 10 + "net/http" 11 + "strings" 12 + 13 + "github.com/bluesky-social/indigo/atproto/crypto" 14 + ) 15 + 16 + // the zero-value of this client is fully functional 17 + type Client struct { 18 + DirectoryURL string 19 + UserAgent *string 20 + HTTPClient http.Client 21 + RotationKey *crypto.PrivateKey 22 + } 23 + 24 + var ( 25 + ErrDIDNotFound = errors.New("DID not found in PLC directory") 26 + DefaultDirectoryURL = "https://plc.directory" 27 + ) 28 + 29 + func (c *Client) Resolve(ctx context.Context, did string) (*Doc, error) { 30 + if !strings.HasPrefix(did, "did:plc:") { 31 + return nil, fmt.Errorf("expected a did:plc, got: %s", did) 32 + } 33 + 34 + plcURL := c.DirectoryURL 35 + if plcURL == "" { 36 + plcURL = DefaultDirectoryURL 37 + } 38 + 39 + url := plcURL + "/" + did 40 + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 41 + if err != nil { 42 + return nil, err 43 + } 44 + if c.UserAgent != nil { 45 + req.Header.Set("User-Agent", *c.UserAgent) 46 + } else { 47 + req.Header.Set("User-Agent", "go-did-method-plc") 48 + } 49 + 50 + resp, err := c.HTTPClient.Do(req) 51 + if err != nil { 52 + return nil, fmt.Errorf("failed did:plc directory resolution: %w", err) 53 + } 54 + if resp.StatusCode == http.StatusNotFound { 55 + return nil, ErrDIDNotFound 56 + } 57 + if resp.StatusCode != http.StatusOK { 58 + return nil, fmt.Errorf("failed did:web well-known fetch, HTTP status: %d", resp.StatusCode) 59 + } 60 + 61 + var doc Doc 62 + if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { 63 + return nil, fmt.Errorf("failed parse of did:plc document JSON: %w", err) 64 + } 65 + return &doc, nil 66 + } 67 + 68 + func (c *Client) Submit(ctx context.Context, did string, op Operation) (*LogEntry, error) { 69 + if !strings.HasPrefix(did, "did:plc:") { 70 + return nil, fmt.Errorf("expected a did:plc, got: %s", did) 71 + } 72 + 73 + plcURL := c.DirectoryURL 74 + if plcURL == "" { 75 + plcURL = DefaultDirectoryURL 76 + } 77 + 78 + var body io.Reader 79 + b, err := json.Marshal(op) 80 + if err != nil { 81 + return nil, err 82 + } 83 + body = bytes.NewReader(b) 84 + 85 + url := plcURL + "/" + did 86 + req, err := http.NewRequestWithContext(ctx, "POST", url, body) 87 + if err != nil { 88 + return nil, err 89 + } 90 + req.Header.Set("Content-Type", "application/json") 91 + if c.UserAgent != nil { 92 + req.Header.Set("User-Agent", *c.UserAgent) 93 + } else { 94 + req.Header.Set("User-Agent", "go-did-method-plc") 95 + } 96 + 97 + resp, err := c.HTTPClient.Do(req) 98 + if err != nil { 99 + return nil, fmt.Errorf("did:plc operation submission failed: %w", err) 100 + } 101 + if resp.StatusCode == http.StatusNotFound { 102 + return nil, ErrDIDNotFound 103 + } 104 + if resp.StatusCode != http.StatusOK { 105 + return nil, fmt.Errorf("failed did:plc operation submission, HTTP status: %d", resp.StatusCode) 106 + } 107 + 108 + var entry LogEntry 109 + if err := json.NewDecoder(resp.Body).Decode(&entry); err != nil { 110 + return nil, fmt.Errorf("failed parse of did:plc op log entry: %w", err) 111 + } 112 + return &entry, nil 113 + } 114 + 115 + func (c *Client) OpLog(ctx context.Context, did string, audit bool) ([]LogEntry, error) { 116 + if !strings.HasPrefix(did, "did:plc:") { 117 + return nil, fmt.Errorf("expected a did:plc, got: %s", did) 118 + } 119 + 120 + plcURL := c.DirectoryURL 121 + if plcURL == "" { 122 + plcURL = DefaultDirectoryURL 123 + } 124 + 125 + url := plcURL + "/" + did + "/log" 126 + if audit { 127 + url += "/audit" 128 + } 129 + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 130 + if err != nil { 131 + return nil, err 132 + } 133 + if c.UserAgent != nil { 134 + req.Header.Set("User-Agent", *c.UserAgent) 135 + } else { 136 + req.Header.Set("User-Agent", "go-did-method-plc") 137 + } 138 + 139 + resp, err := c.HTTPClient.Do(req) 140 + if err != nil { 141 + return nil, fmt.Errorf("failed did:plc directory resolution: %w", err) 142 + } 143 + if resp.StatusCode == http.StatusNotFound { 144 + return nil, ErrDIDNotFound 145 + } 146 + if resp.StatusCode != http.StatusOK { 147 + return nil, fmt.Errorf("failed did:web well-known fetch, HTTP status: %d", resp.StatusCode) 148 + } 149 + 150 + var entries []LogEntry 151 + if err := json.NewDecoder(resp.Body).Decode(&entries); err != nil { 152 + return nil, fmt.Errorf("failed parse of did:plc document JSON: %w", err) 153 + } 154 + return entries, nil 155 + }
+23
didplc/diddoc.go
··· 1 + package didplc 2 + 3 + import () 4 + 5 + type DocVerificationMethod struct { 6 + ID string `json:"id"` 7 + Type string `json:"type"` 8 + Controller string `json:"controller"` 9 + PublicKeyMultibase string `json:"publicKeyMultibase"` 10 + } 11 + 12 + type DocService struct { 13 + ID string `json:"id"` 14 + Type string `json:"type"` 15 + ServiceEndpoint string `json:"serviceEndpoint"` 16 + } 17 + 18 + type Doc struct { 19 + ID string `json:"id"` 20 + AlsoKnownAs []string `json:"alsoKnownAs,omitempty"` 21 + VerificationMethod []DocVerificationMethod `json:"verificationMethod,omitempty"` 22 + Service []DocService `json:"service,omitempty"` 23 + }
+174
didplc/log.go
··· 1 + package didplc 2 + 3 + import ( 4 + "fmt" 5 + "time" 6 + 7 + "github.com/bluesky-social/indigo/atproto/crypto" 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 + ) 10 + 11 + type LogEntry struct { 12 + DID string `json:"did"` 13 + Operation OpEnum `json:"operation"` 14 + CID string `json:"cid"` 15 + Nullified bool `json:"nullified"` 16 + CreatedAt string `json:"createdAt"` 17 + } 18 + 19 + // Checks self-consistency of this log entry in isolation. Does not access other context or log entries. 20 + func (le *LogEntry) Validate() error { 21 + 22 + if le.Operation.Regular != nil { 23 + if le.CID != le.Operation.Regular.CID().String() { 24 + return fmt.Errorf("log entry CID didn't match computed operation CID") 25 + } 26 + // NOTE: for non-genesis ops, the rotation key may have bene in a previous op 27 + if le.Operation.Regular.IsGenesis() { 28 + did, err := le.Operation.Regular.DID() 29 + if err != nil { 30 + return err 31 + } 32 + if le.DID != did { 33 + return fmt.Errorf("log entry DID didn't match computed genesis operation DID") 34 + } 35 + if err := VerifySignatureAny(le.Operation.Regular, le.Operation.Regular.RotationKeys); err != nil { 36 + return fmt.Errorf("failed to validate op genesis signature: %v", err) 37 + } 38 + } 39 + } else if le.Operation.Legacy != nil { 40 + if le.CID != le.Operation.Legacy.CID().String() { 41 + return fmt.Errorf("log entry CID didn't match computed operation CID") 42 + } 43 + // NOTE: for non-genesis ops, the rotation key may have bene in a previous op 44 + if le.Operation.Legacy.IsGenesis() { 45 + did, err := le.Operation.Legacy.DID() 46 + if err != nil { 47 + return err 48 + } 49 + if le.DID != did { 50 + return fmt.Errorf("log entry DID didn't match computed genesis operation DID") 51 + } 52 + // TODO: try both signing and recovery key? 53 + pub, err := crypto.ParsePublicDIDKey(le.Operation.Legacy.SigningKey) 54 + if err != nil { 55 + return fmt.Errorf("could not parse recovery key: %v", err) 56 + } 57 + if err := le.Operation.Legacy.VerifySignature(pub); err != nil { 58 + return fmt.Errorf("failed to validate legacy op genesis signature: %v", err) 59 + } 60 + } 61 + } else if le.Operation.Tombstone != nil { 62 + if le.CID != le.Operation.Tombstone.CID().String() { 63 + return fmt.Errorf("log entry CID didn't match computed operation CID") 64 + } 65 + // NOTE: for tombstones, the rotation key is always in a previous op 66 + } else { 67 + return fmt.Errorf("expected tombstone, legacy, or regular PLC operation") 68 + } 69 + 70 + return nil 71 + } 72 + 73 + // checks and ordered list of operations for a single DID. 74 + // 75 + // can be a full audit log (with nullified entries), or a simple log (only "active" entries) 76 + func VerifyOpLog(entries []LogEntry) error { 77 + if len(entries) == 0 { 78 + return fmt.Errorf("can't verify empty operation log") 79 + } 80 + tombstoned := false 81 + earliestNullified := "" 82 + lastTS := "" 83 + var last *RegularOp 84 + var err error 85 + 86 + for _, oe := range entries { 87 + var op RegularOp 88 + 89 + if err = oe.Validate(); err != nil { 90 + return err 91 + } 92 + 93 + if last == nil { 94 + // special processing of first operation 95 + if oe.Operation.Regular != nil { 96 + op = *oe.Operation.Regular 97 + } else if oe.Operation.Legacy != nil { 98 + op = oe.Operation.Legacy.RegularOp() 99 + } else { 100 + return fmt.Errorf("first log entry must be a plc_operation or create (legacy)") 101 + } 102 + 103 + err := VerifySignatureAny(&op, op.RotationKeys) 104 + if err != nil { 105 + return err 106 + } 107 + 108 + if oe.Nullified { 109 + return fmt.Errorf("first log entry can't be nullified") 110 + } 111 + 112 + last = &op 113 + lastTS = oe.CreatedAt 114 + continue 115 + } 116 + 117 + if oe.CreatedAt < lastTS { 118 + return fmt.Errorf("operation log was not ordered by timestamp") 119 + } 120 + if tombstoned { 121 + return fmt.Errorf("account was successfully tombstoned, expect end of op log") 122 + } 123 + 124 + if !oe.Nullified && earliestNullified != "" { 125 + earliest, err := syntax.ParseDatetime(earliestNullified) 126 + if err != nil { 127 + return err 128 + } 129 + current, err := syntax.ParseDatetime(oe.CreatedAt) 130 + if err != nil { 131 + return err 132 + } 133 + if current.Time().Sub(earliest.Time()) > 72*time.Hour { 134 + return fmt.Errorf("time gap between nullified event and overriding event more than recovery window") 135 + } 136 + earliestNullified = "" 137 + } 138 + 139 + if oe.Nullified && earliestNullified == "" { 140 + earliestNullified = oe.CreatedAt 141 + } 142 + 143 + if oe.Operation.Tombstone != nil { 144 + if err := VerifySignatureAny(oe.Operation.Tombstone, last.RotationKeys); err != nil { 145 + return err 146 + } 147 + if oe.Nullified { 148 + continue 149 + } 150 + tombstoned = true 151 + lastTS = oe.CreatedAt 152 + continue 153 + } else if oe.Operation.Regular != nil { 154 + op = *oe.Operation.Regular 155 + } else { 156 + return fmt.Errorf("expected a plc_operation or plc_tombstone operation") 157 + } 158 + 159 + if err := VerifySignatureAny(&op, last.RotationKeys); err != nil { 160 + return err 161 + } 162 + if oe.Nullified { 163 + continue 164 + } else { 165 + last = &op 166 + lastTS = oe.CreatedAt 167 + } 168 + } 169 + 170 + if earliestNullified != "" { 171 + return fmt.Errorf("outstanding 'nullified' op at end of log") 172 + } 173 + return nil 174 + }
+1
didplc/main.go
··· 1 + package didplc
+129
didplc/manual_test.go
··· 1 + package didplc 2 + 3 + import ( 4 + "encoding/base64" 5 + "testing" 6 + 7 + "github.com/bluesky-social/indigo/atproto/crypto" 8 + 9 + cbor "github.com/ipfs/go-ipld-cbor" 10 + "github.com/stretchr/testify/assert" 11 + ) 12 + 13 + func TestVerifySignatureHardWay(t *testing.T) { 14 + assert := assert.New(t) 15 + 16 + sig := "n-VWsPZY4xkFN8wlg-kJBU_yzWTNd2oBnbjkjxXu3HdjbBLaEB7K39JHIPn_DZVALKRjts6bUicjSEecZy8eIw" 17 + didKey := "did:key:zQ3shP5TBe1sQfSttXty15FAEHV1DZgcxRZNxvEWnPfLFwLxJ" 18 + pub, err := crypto.ParsePublicDIDKey(didKey) 19 + if err != nil { 20 + t.Fatal(err) 21 + } 22 + 23 + obj := map[string]interface{}{ 24 + "prev": "bafyreigcxay6ucqlwowfpu35alyxqtv3c4vsj7gmdtmnidsnqs6nblyarq", 25 + "type": "plc_operation", 26 + "services": map[string]any{ 27 + "atproto_pds": map[string]string{ 28 + "type": "AtprotoPersonalDataServer", 29 + "endpoint": "https://bsky.social", 30 + }, 31 + }, 32 + "alsoKnownAs": []string{ 33 + "at://dholms.xyz", 34 + }, 35 + "rotationKeys": []string{ 36 + "did:key:zQ3shhCGUqDKjStzuDxPkTxN6ujddP4RkEKJJouJGRRkaLGbg", 37 + "did:key:zQ3shP5TBe1sQfSttXty15FAEHV1DZgcxRZNxvEWnPfLFwLxJ", 38 + }, 39 + "verificationMethods": map[string]string{ 40 + "atproto": "did:key:zQ3shP5TBe1sQfSttXty15FAEHV1DZgcxRZNxvEWnPfLFwLxJ", 41 + }, 42 + //"sig": nil, 43 + } 44 + objBytes, err := cbor.DumpObject(obj) 45 + if err != nil { 46 + t.Fatal(err) 47 + } 48 + 49 + sigBytes, err := base64.RawURLEncoding.DecodeString(sig) 50 + if err != nil { 51 + t.Fatal(err) 52 + } 53 + //fmt.Println(len(sigBytes)) 54 + assert.NoError(pub.HashAndVerify(objBytes, sigBytes)) 55 + } 56 + 57 + func TestVerifySignatureHardWayNew(t *testing.T) { 58 + assert := assert.New(t) 59 + 60 + sig := "v9rHEhW4XVwMKRSd2yeFgk4-mZthHSZwJ4tShNPqDP4NH3w79CkxIOmJ393D6MEyWZLN1qxS1qBIbFEGtfoDDw" 61 + didKey := "did:key:zQ3shcciz4AvrLyDnUdZLpQys3kyCsesojRNzJAieyDStGxGo" 62 + pub, err := crypto.ParsePublicDIDKey(didKey) 63 + if err != nil { 64 + t.Fatal(err) 65 + } 66 + 67 + obj := map[string]interface{}{ 68 + "prev": nil, 69 + "type": "plc_operation", 70 + "services": map[string]any{ 71 + "atproto_pds": map[string]string{ 72 + "type": "AtprotoPersonalDataServer", 73 + "endpoint": "https://pds.robocracy.org", 74 + }, 75 + }, 76 + "alsoKnownAs": []string{ 77 + "at://bnewbold.pds.robocracy.org", 78 + }, 79 + "rotationKeys": []string{ 80 + "did:key:zQ3shcciz4AvrLyDnUdZLpQys3kyCsesojRNzJAieyDStGxGo", 81 + }, 82 + "verificationMethods": map[string]string{ 83 + "atproto": "did:key:zQ3shazA2airLo8gNJvxGMFZWPJDRkLGNR6mn9Txsc8YYndwy", 84 + }, 85 + //"sig": nil, 86 + } 87 + objBytes, err := cbor.DumpObject(obj) 88 + if err != nil { 89 + t.Fatal(err) 90 + } 91 + 92 + sigBytes, err := base64.RawURLEncoding.DecodeString(sig) 93 + if err != nil { 94 + t.Fatal(err) 95 + } 96 + assert.NoError(pub.HashAndVerify(objBytes, sigBytes)) 97 + assert.Equal("bafyreih7k7a7v7ez7qzzxj7ywomk5hgtidpzuodjsw2kldtepdadob4hdi", computeCID(objBytes).String()) 98 + } 99 + 100 + func TestVerifySignatureLegacyGenesis(t *testing.T) { 101 + assert := assert.New(t) 102 + 103 + sig := "7QTzqO1BcL3eDzP4P_YBxMmv5U4brHzAItkM9w5o8gZA7ElZkrVYEwsfQCfk5EoWLk58Z1y6fyNP9x1pthJnlw" 104 + didKey := "did:key:zQ3shP5TBe1sQfSttXty15FAEHV1DZgcxRZNxvEWnPfLFwLxJ" // signing, not recovery 105 + pub, err := crypto.ParsePublicDIDKey(didKey) 106 + if err != nil { 107 + t.Fatal(err) 108 + } 109 + 110 + obj := map[string]interface{}{ 111 + "prev": nil, 112 + "type": "create", 113 + "handle": "dan.bsky.social", 114 + "service": "https://bsky.social", 115 + "signingKey": "did:key:zQ3shP5TBe1sQfSttXty15FAEHV1DZgcxRZNxvEWnPfLFwLxJ", 116 + "recoveryKey": "did:key:zQ3shhCGUqDKjStzuDxPkTxN6ujddP4RkEKJJouJGRRkaLGbg", 117 + //"sig": nil, 118 + } 119 + objBytes, err := cbor.DumpObject(obj) 120 + if err != nil { 121 + t.Fatal(err) 122 + } 123 + 124 + sigBytes, err := base64.RawURLEncoding.DecodeString(sig) 125 + if err != nil { 126 + t.Fatal(err) 127 + } 128 + assert.NoError(pub.HashAndVerify(objBytes, sigBytes)) 129 + }
+471
didplc/operation.go
··· 1 + package didplc 2 + 3 + import ( 4 + "crypto/sha256" 5 + "encoding/base32" 6 + "encoding/base64" 7 + "encoding/json" 8 + "errors" 9 + "fmt" 10 + "strings" 11 + 12 + "github.com/bluesky-social/indigo/atproto/crypto" 13 + 14 + "github.com/ipfs/go-cid" 15 + cbor "github.com/ipfs/go-ipld-cbor" 16 + ) 17 + 18 + type Operation interface { 19 + // CID of the full (signed) operation 20 + CID() cid.Cid 21 + // serializes a copy of the op as CBOR, with the `sig` field omitted 22 + UnsignedCBORBytes() []byte 23 + // serializes a copy of the op as CBOR, with the `sig` field included 24 + SignedCBORBytes() []byte 25 + // whether this operation is a genesis (creation) op 26 + IsGenesis() bool 27 + // whether this operation has a signature or is unsigned 28 + IsSigned() bool 29 + // returns the DID for a genesis op (errors if this op is not a genesis op) 30 + DID() (string, error) 31 + // signs the object in-place 32 + Sign(priv crypto.PrivateKey) error 33 + // verifiy signature. returns crypto.ErrInvalidSignature if appropriate 34 + VerifySignature(pub crypto.PublicKey) error 35 + // returns a DID doc 36 + Doc(did string) (Doc, error) 37 + } 38 + 39 + type OpService struct { 40 + Type string `json:"type" cborgen:"type"` 41 + Endpoint string `json:"endpoint" cborgen:"endpoint"` 42 + } 43 + 44 + type RegularOp struct { 45 + Type string `json:"type,const=plc_operation" cborgen:"type,const=plc_operation"` 46 + RotationKeys []string `json:"rotationKeys" cborgen:"rotationKeys"` 47 + VerificationMethods map[string]string `json:"verificationMethods" cborgen:"verificationMethods"` 48 + AlsoKnownAs []string `json:"alsoKnownAs" cborgen:"alsoKnownAs"` 49 + Services map[string]OpService `json:"services" cborgen:"services"` 50 + Prev *string `json:"prev" cborgen:"prev"` 51 + Sig *string `json:"sig,omitempty" cborgen:"sig,omitempty" refmt:"sig,omitempty"` 52 + } 53 + 54 + type TombstoneOp struct { 55 + Type string `json:"type,const=plc_tombstone" cborgen:"type,const=plc_tombstone"` 56 + Prev string `json:"prev" cborgen:"prev"` 57 + Sig *string `json:"sig,omitempty" cborgen:"sig,omitempty" refmt:"sig,omitempty"` 58 + } 59 + 60 + type LegacyOp struct { 61 + Type string `json:"type,const=create" cborgen:"type,const=create"` 62 + SigningKey string `json:"signingKey" cborgen:"signingKey"` 63 + RecoveryKey string `json:"recoveryKey" cborgen:"recoveryKey"` 64 + Handle string `json:"handle" cborgen:"handle"` 65 + Service string `json:"service" cborgen:"service"` 66 + Prev *string `json:"prev" cborgen:"prev"` 67 + Sig *string `json:"sig,omitempty" cborgen:"sig,omitempty" refmt:"sig,omitempty"` 68 + } 69 + 70 + var _ Operation = (*RegularOp)(nil) 71 + var _ Operation = (*TombstoneOp)(nil) 72 + var _ Operation = (*LegacyOp)(nil) 73 + 74 + // any of: Op, TombstoneOp, or LegacyOp 75 + type OpEnum struct { 76 + Regular *RegularOp 77 + Tombstone *TombstoneOp 78 + Legacy *LegacyOp 79 + } 80 + 81 + var ErrNotGenesisOp = errors.New("not a genesis PLC operation") 82 + 83 + func init() { 84 + cbor.RegisterCborType(OpService{}) 85 + cbor.RegisterCborType(RegularOp{}) 86 + cbor.RegisterCborType(TombstoneOp{}) 87 + cbor.RegisterCborType(LegacyOp{}) 88 + } 89 + 90 + func computeCID(b []byte) cid.Cid { 91 + cidBuilder := cid.V1Builder{Codec: 0x71, MhType: 0x12, MhLength: 0} 92 + c, err := cidBuilder.Sum(b) 93 + if err != nil { 94 + return cid.Undef 95 + } 96 + return c 97 + } 98 + 99 + func (op *RegularOp) CID() cid.Cid { 100 + return computeCID(op.SignedCBORBytes()) 101 + } 102 + 103 + func (op *RegularOp) UnsignedCBORBytes() []byte { 104 + unsigned := RegularOp{ 105 + Type: op.Type, 106 + RotationKeys: op.RotationKeys, 107 + VerificationMethods: op.VerificationMethods, 108 + AlsoKnownAs: op.AlsoKnownAs, 109 + Services: op.Services, 110 + Prev: op.Prev, 111 + Sig: nil, 112 + } 113 + 114 + out, err := cbor.DumpObject(unsigned) 115 + if err != nil { 116 + return nil 117 + } 118 + return out 119 + } 120 + 121 + func (op *RegularOp) SignedCBORBytes() []byte { 122 + out, err := cbor.DumpObject(op) 123 + if err != nil { 124 + return nil 125 + } 126 + return out 127 + } 128 + 129 + func (op *RegularOp) IsGenesis() bool { 130 + return op.Prev == nil 131 + } 132 + 133 + func (op *RegularOp) IsSigned() bool { 134 + return op.Sig != nil && *op.Sig != "" 135 + } 136 + 137 + func (op *RegularOp) DID() (string, error) { 138 + if !op.IsGenesis() { 139 + return "", ErrNotGenesisOp 140 + } 141 + hash := sha256.Sum256(op.SignedCBORBytes()) 142 + suffix := base32.StdEncoding.EncodeToString(hash[:])[:24] 143 + return "did:plc:" + strings.ToLower(suffix), nil 144 + } 145 + 146 + func signOp(op Operation, priv crypto.PrivateKey) (string, error) { 147 + b := op.UnsignedCBORBytes() 148 + sig, err := priv.HashAndSign(b) 149 + if err != nil { 150 + return "", err 151 + } 152 + b64 := base64.RawURLEncoding.EncodeToString(sig) 153 + return b64, nil 154 + } 155 + 156 + func (op *RegularOp) Sign(priv crypto.PrivateKey) error { 157 + sig, err := signOp(op, priv) 158 + if err != nil { 159 + return err 160 + } 161 + op.Sig = &sig 162 + return nil 163 + } 164 + 165 + func verifySigOp(op Operation, pub crypto.PublicKey, sig *string) error { 166 + if sig == nil || *sig == "" { 167 + return fmt.Errorf("can't verify empty signature") 168 + } 169 + b := op.UnsignedCBORBytes() 170 + sigBytes, err := base64.RawURLEncoding.DecodeString(*sig) 171 + if err != nil { 172 + return err 173 + } 174 + return pub.HashAndVerify(b, sigBytes) 175 + } 176 + 177 + // parsing errors are not ignored (will be returned immediately if found) 178 + func VerifySignatureAny(op Operation, didKeys []string) error { 179 + if len(didKeys) == 0 { 180 + return fmt.Errorf("no keys to verify against") 181 + } 182 + for _, dk := range didKeys { 183 + pub, err := crypto.ParsePublicDIDKey(dk) 184 + if err != nil { 185 + return err 186 + } 187 + err = op.VerifySignature(pub) 188 + if err != crypto.ErrInvalidSignature { 189 + return err 190 + } 191 + if nil == err { 192 + return nil 193 + } 194 + } 195 + return crypto.ErrInvalidSignature 196 + } 197 + 198 + func (op *RegularOp) VerifySignature(pub crypto.PublicKey) error { 199 + return verifySigOp(op, pub, op.Sig) 200 + } 201 + 202 + func (op *RegularOp) Doc(did string) (Doc, error) { 203 + svc := []DocService{} 204 + for key, s := range op.Services { 205 + svc = append(svc, DocService{ 206 + ID: did + "#" + key, 207 + Type: s.Type, 208 + ServiceEndpoint: s.Endpoint, 209 + }) 210 + } 211 + vm := []DocVerificationMethod{} 212 + for name, didKey := range op.VerificationMethods { 213 + pub, err := crypto.ParsePublicDIDKey(didKey) 214 + if err != nil { 215 + return Doc{}, err 216 + } 217 + vm = append(vm, DocVerificationMethod{ 218 + ID: did + "#" + name, 219 + Type: "Multikey", 220 + Controller: did, 221 + PublicKeyMultibase: pub.Multibase(), 222 + }) 223 + } 224 + doc := Doc{ 225 + ID: did, 226 + AlsoKnownAs: op.AlsoKnownAs, 227 + VerificationMethod: vm, 228 + Service: svc, 229 + } 230 + return doc, nil 231 + } 232 + 233 + func (op *LegacyOp) CID() cid.Cid { 234 + return computeCID(op.SignedCBORBytes()) 235 + } 236 + 237 + func (op *LegacyOp) UnsignedCBORBytes() []byte { 238 + unsigned := LegacyOp{ 239 + Type: op.Type, 240 + SigningKey: op.SigningKey, 241 + RecoveryKey: op.RecoveryKey, 242 + Handle: op.Handle, 243 + Service: op.Service, 244 + Prev: op.Prev, 245 + Sig: nil, 246 + } 247 + out, err := cbor.DumpObject(unsigned) 248 + if err != nil { 249 + return nil 250 + } 251 + return out 252 + } 253 + 254 + func (op *LegacyOp) SignedCBORBytes() []byte { 255 + out, err := cbor.DumpObject(op) 256 + if err != nil { 257 + return nil 258 + } 259 + return out 260 + } 261 + 262 + func (op *LegacyOp) IsGenesis() bool { 263 + return op.Prev == nil 264 + } 265 + 266 + func (op *LegacyOp) IsSigned() bool { 267 + return op.Sig != nil && *op.Sig != "" 268 + } 269 + 270 + func (op *LegacyOp) DID() (string, error) { 271 + if !op.IsGenesis() { 272 + return "", ErrNotGenesisOp 273 + } 274 + hash := sha256.Sum256(op.SignedCBORBytes()) 275 + suffix := base32.StdEncoding.EncodeToString(hash[:])[:24] 276 + return "did:plc:" + strings.ToLower(suffix), nil 277 + } 278 + 279 + func (op *LegacyOp) Sign(priv crypto.PrivateKey) error { 280 + sig, err := signOp(op, priv) 281 + if err != nil { 282 + return err 283 + } 284 + op.Sig = &sig 285 + return nil 286 + } 287 + 288 + func (op *LegacyOp) VerifySignature(pub crypto.PublicKey) error { 289 + return verifySigOp(op, pub, op.Sig) 290 + } 291 + 292 + func (op *LegacyOp) Doc(did string) (Doc, error) { 293 + // NOTE: could re-implement this by calling op.RegularOp().Doc() 294 + svc := []DocService{ 295 + DocService{ 296 + ID: did + "#atproto_pds", 297 + Type: "AtprotoPersonalDataServer", 298 + ServiceEndpoint: op.Service, 299 + }, 300 + } 301 + vm := []DocVerificationMethod{ 302 + DocVerificationMethod{ 303 + ID: did + "#atproto", 304 + Type: "Multikey", 305 + Controller: did, 306 + PublicKeyMultibase: strings.TrimPrefix(op.SigningKey, "did:key:"), 307 + }, 308 + } 309 + doc := Doc{ 310 + ID: did, 311 + AlsoKnownAs: []string{"at://" + op.Handle}, 312 + VerificationMethod: vm, 313 + Service: svc, 314 + } 315 + return doc, nil 316 + } 317 + 318 + // converts a legacy "create" op to an (unsigned) "plc_operation" 319 + func (op *LegacyOp) RegularOp() RegularOp { 320 + return RegularOp{ 321 + RotationKeys: []string{op.RecoveryKey}, 322 + VerificationMethods: map[string]string{ 323 + "atproto": op.SigningKey, 324 + }, 325 + AlsoKnownAs: []string{"at://" + op.Handle}, 326 + Services: map[string]OpService{ 327 + "atproto_pds": OpService{ 328 + Type: "AtprotoPersonalDataServer", 329 + Endpoint: op.Service, 330 + }, 331 + }, 332 + Prev: nil, // always a create 333 + Sig: nil, // don't have private key 334 + } 335 + } 336 + 337 + func (op *TombstoneOp) CID() cid.Cid { 338 + return computeCID(op.SignedCBORBytes()) 339 + } 340 + 341 + func (op *TombstoneOp) UnsignedCBORBytes() []byte { 342 + unsigned := TombstoneOp{ 343 + Type: op.Type, 344 + Prev: op.Prev, 345 + Sig: nil, 346 + } 347 + out, err := cbor.DumpObject(unsigned) 348 + if err != nil { 349 + return nil 350 + } 351 + return out 352 + } 353 + 354 + func (op *TombstoneOp) SignedCBORBytes() []byte { 355 + out, err := cbor.DumpObject(op) 356 + if err != nil { 357 + return nil 358 + } 359 + return out 360 + } 361 + 362 + func (op *TombstoneOp) IsGenesis() bool { 363 + return false 364 + } 365 + 366 + func (op *TombstoneOp) IsSigned() bool { 367 + return op.Sig != nil && *op.Sig != "" 368 + } 369 + 370 + func (op *TombstoneOp) DID() (string, error) { 371 + return "", ErrNotGenesisOp 372 + } 373 + 374 + func (op *TombstoneOp) Sign(priv crypto.PrivateKey) error { 375 + sig, err := signOp(op, priv) 376 + if err != nil { 377 + return err 378 + } 379 + op.Sig = &sig 380 + return nil 381 + } 382 + 383 + func (op *TombstoneOp) VerifySignature(pub crypto.PublicKey) error { 384 + return verifySigOp(op, pub, op.Sig) 385 + } 386 + 387 + func (op *TombstoneOp) Doc(did string) (Doc, error) { 388 + return Doc{}, fmt.Errorf("tombstones do not have a DID document representation") 389 + } 390 + 391 + func (o *OpEnum) MarshalJSON() ([]byte, error) { 392 + if o.Regular != nil { 393 + return json.Marshal(o.Regular) 394 + } else if o.Legacy != nil { 395 + return json.Marshal(o.Legacy) 396 + } else if o.Tombstone != nil { 397 + return json.Marshal(o.Tombstone) 398 + } 399 + return nil, fmt.Errorf("can't marshal empty OpEnum") 400 + } 401 + 402 + func (o *OpEnum) UnmarshalJSON(b []byte) error { 403 + var typeMap map[string]interface{} 404 + err := json.Unmarshal(b, &typeMap) 405 + if err != nil { 406 + return err 407 + } 408 + typ, ok := typeMap["type"] 409 + if !ok { 410 + return fmt.Errorf("did not find expected operation 'type' field") 411 + } 412 + 413 + switch typ { 414 + case "plc_operation": 415 + o.Regular = &RegularOp{} 416 + return json.Unmarshal(b, o.Regular) 417 + case "create": 418 + o.Legacy = &LegacyOp{} 419 + return json.Unmarshal(b, o.Legacy) 420 + case "plc_tombstone": 421 + o.Tombstone = &TombstoneOp{} 422 + return json.Unmarshal(b, o.Tombstone) 423 + default: 424 + return fmt.Errorf("unexpected operation type: %s", typ) 425 + } 426 + } 427 + 428 + // returns a new signed PLC operation using the provided atproto-specific metdata 429 + func NewAtproto(priv crypto.PrivateKey, handle string, pdsEndpoint string, rotationKeys []string) (RegularOp, error) { 430 + 431 + pub, err := priv.PublicKey() 432 + if err != nil { 433 + return RegularOp{}, err 434 + } 435 + if len(rotationKeys) == 0 { 436 + return RegularOp{}, fmt.Errorf("at least one rotation key is required") 437 + } 438 + handleURI := "at://" + handle 439 + op := RegularOp{ 440 + RotationKeys: rotationKeys, 441 + VerificationMethods: map[string]string{ 442 + "atproto": pub.DIDKey(), 443 + }, 444 + AlsoKnownAs: []string{handleURI}, 445 + Services: map[string]OpService{ 446 + "atproto_pds": OpService{ 447 + Type: "AtprotoPersonalDataServer", 448 + Endpoint: pdsEndpoint, 449 + }, 450 + }, 451 + Prev: nil, 452 + Sig: nil, 453 + } 454 + if err := op.Sign(priv); err != nil { 455 + return RegularOp{}, err 456 + } 457 + return op, nil 458 + } 459 + 460 + func (oe *OpEnum) AsOperation() Operation { 461 + if oe.Regular != nil { 462 + return oe.Regular 463 + } else if oe.Legacy != nil { 464 + return oe.Legacy 465 + } else if oe.Tombstone != nil { 466 + return oe.Tombstone 467 + } else { 468 + // TODO; something more safe here? 469 + return nil 470 + } 471 + }
+101
didplc/operation_test.go
··· 1 + package didplc 2 + 3 + import ( 4 + "encoding/json" 5 + "io" 6 + "os" 7 + "testing" 8 + 9 + "github.com/bluesky-social/indigo/atproto/crypto" 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + 12 + "github.com/stretchr/testify/assert" 13 + ) 14 + 15 + func loadTestLogEntries(t *testing.T, p string) []LogEntry { 16 + f, err := os.Open(p) 17 + if err != nil { 18 + t.Fatal(err) 19 + } 20 + defer func() { _ = f.Close() }() 21 + 22 + fileBytes, err := io.ReadAll(f) 23 + if err != nil { 24 + t.Fatal(err) 25 + } 26 + 27 + var entries []LogEntry 28 + if err := json.Unmarshal(fileBytes, &entries); err != nil { 29 + t.Fatal(err) 30 + } 31 + 32 + return entries 33 + } 34 + 35 + func TestLogEntryValidate(t *testing.T) { 36 + assert := assert.New(t) 37 + 38 + list := []string{ 39 + "testdata/log_bskyapp.json", 40 + "testdata/log_legacy_dholms.json", 41 + "testdata/log_bnewbold_robocracy.json", 42 + } 43 + for _, p := range list { 44 + entries := loadTestLogEntries(t, p) 45 + for _, le := range entries { 46 + assert.NoError(le.Validate()) 47 + } 48 + } 49 + } 50 + 51 + func TestCreatePLC(t *testing.T) { 52 + assert := assert.New(t) 53 + 54 + priv, err := crypto.GeneratePrivateKeyP256() 55 + if err != nil { 56 + t.Fatal(err) 57 + } 58 + pub, err := priv.PublicKey() 59 + if err != nil { 60 + t.Fatal(err) 61 + } 62 + pubDIDKey := pub.DIDKey() 63 + handleURI := "at://handle.example.com" 64 + endpoint := "https://pds.example.com" 65 + op := RegularOp{ 66 + Type: "plc_operation", 67 + RotationKeys: []string{pubDIDKey}, 68 + VerificationMethods: map[string]string{ 69 + "atproto": pubDIDKey, 70 + }, 71 + AlsoKnownAs: []string{handleURI}, 72 + Services: map[string]OpService{ 73 + "atproto_pds": OpService{ 74 + Type: "AtprotoPersonalDataServer", 75 + Endpoint: endpoint, 76 + }, 77 + }, 78 + Prev: nil, 79 + Sig: nil, 80 + } 81 + assert.NoError(op.Sign(priv)) 82 + assert.NoError(op.VerifySignature(pub)) 83 + did, err := op.DID() 84 + if err != nil { 85 + t.Fatal(err) 86 + } 87 + _, err = syntax.ParseDID(did) 88 + assert.NoError(err) 89 + 90 + le := LogEntry{ 91 + DID: did, 92 + Operation: OpEnum{Regular: &op}, 93 + CID: op.CID().String(), 94 + Nullified: false, 95 + CreatedAt: syntax.DatetimeNow().String(), 96 + } 97 + assert.NoError(le.Validate()) 98 + 99 + _, err = op.Doc(did) 100 + assert.NoError(err) 101 + }
+54
didplc/testdata/log_bnewbold_robocracy.json
··· 1 + [ 2 + { 3 + "did": "did:plc:nhxcyu4ewwhl5pqil4dotqjo", 4 + "operation": { 5 + "sig": "v9rHEhW4XVwMKRSd2yeFgk4-mZthHSZwJ4tShNPqDP4NH3w79CkxIOmJ393D6MEyWZLN1qxS1qBIbFEGtfoDDw", 6 + "prev": null, 7 + "type": "plc_operation", 8 + "services": { 9 + "atproto_pds": { 10 + "type": "AtprotoPersonalDataServer", 11 + "endpoint": "https://pds.robocracy.org" 12 + } 13 + }, 14 + "alsoKnownAs": [ 15 + "at://bnewbold.pds.robocracy.org" 16 + ], 17 + "rotationKeys": [ 18 + "did:key:zQ3shcciz4AvrLyDnUdZLpQys3kyCsesojRNzJAieyDStGxGo" 19 + ], 20 + "verificationMethods": { 21 + "atproto": "did:key:zQ3shazA2airLo8gNJvxGMFZWPJDRkLGNR6mn9Txsc8YYndwy" 22 + } 23 + }, 24 + "cid": "bafyreidj5ywfhbfvr27l4cc7a3u4clup5vx343ufuzdliethuhzzxjbg4q", 25 + "nullified": false, 26 + "createdAt": "2024-02-22T04:31:08.867Z" 27 + }, 28 + { 29 + "did": "did:plc:nhxcyu4ewwhl5pqil4dotqjo", 30 + "operation": { 31 + "sig": "P8TrUomEKSnJpyuoyqdaqv-KilKbQKoi6MNf8DNN8LdFn1cA3_BtkqVYAjmucpQ8DDSze-jG4YDvC6HFK9QPOA", 32 + "prev": "bafyreidj5ywfhbfvr27l4cc7a3u4clup5vx343ufuzdliethuhzzxjbg4q", 33 + "type": "plc_operation", 34 + "services": { 35 + "atproto_pds": { 36 + "type": "AtprotoPersonalDataServer", 37 + "endpoint": "https://pds.robocracy.org" 38 + } 39 + }, 40 + "alsoKnownAs": [ 41 + "at://bnewbold.robocracy.org" 42 + ], 43 + "rotationKeys": [ 44 + "did:key:zQ3shcciz4AvrLyDnUdZLpQys3kyCsesojRNzJAieyDStGxGo" 45 + ], 46 + "verificationMethods": { 47 + "atproto": "did:key:zQ3shazA2airLo8gNJvxGMFZWPJDRkLGNR6mn9Txsc8YYndwy" 48 + } 49 + }, 50 + "cid": "bafyreiemmb2ephqyt7orhiv4vp7gmdohnzhgkehwzfrvirgvg7fg352ysi", 51 + "nullified": false, 52 + "createdAt": "2024-02-22T04:39:57.282Z" 53 + } 54 + ]
+56
didplc/testdata/log_bskyapp.json
··· 1 + [ 2 + { 3 + "did": "did:plc:z72i7hdynmk6r22z27h6tvur", 4 + "operation": { 5 + "sig": "9NuYV7AqwHVTc0YuWzNV3CJafsSZWH7qCxHRUIP2xWlB-YexXC1OaYAnUayiCXLVzRQ8WBXIqF-SvZdNalwcjA", 6 + "prev": null, 7 + "type": "plc_operation", 8 + "services": { 9 + "atproto_pds": { 10 + "type": "AtprotoPersonalDataServer", 11 + "endpoint": "https://bsky.social" 12 + } 13 + }, 14 + "alsoKnownAs": [ 15 + "at://bluesky-team.bsky.social" 16 + ], 17 + "rotationKeys": [ 18 + "did:key:zQ3shhCGUqDKjStzuDxPkTxN6ujddP4RkEKJJouJGRRkaLGbg", 19 + "did:key:zQ3shpKnbdPx3g3CmPf5cRVTPe1HtSwVn5ish3wSnDPQCbLJK" 20 + ], 21 + "verificationMethods": { 22 + "atproto": "did:key:zQ3shXjHeiBuRCKmM36cuYnm7YEMzhGnCmCyW92sRJ9pribSF" 23 + } 24 + }, 25 + "cid": "bafyreigp6shzy6dlcxuowwoxz7u5nemdrkad2my5zwzpwilcnhih7bw6zm", 26 + "nullified": false, 27 + "createdAt": "2023-04-12T04:53:57.057Z" 28 + }, 29 + { 30 + "did": "did:plc:z72i7hdynmk6r22z27h6tvur", 31 + "operation": { 32 + "sig": "1mEWzRtFOgeRXH-YCSPTxb990JOXxa__n8Qw6BOKl7Ndm6OFFmwYKiiMqMCpAbxpnGjF5abfIsKc7u3a77Cbnw", 33 + "prev": "bafyreigp6shzy6dlcxuowwoxz7u5nemdrkad2my5zwzpwilcnhih7bw6zm", 34 + "type": "plc_operation", 35 + "services": { 36 + "atproto_pds": { 37 + "type": "AtprotoPersonalDataServer", 38 + "endpoint": "https://bsky.social" 39 + } 40 + }, 41 + "alsoKnownAs": [ 42 + "at://bsky.app" 43 + ], 44 + "rotationKeys": [ 45 + "did:key:zQ3shhCGUqDKjStzuDxPkTxN6ujddP4RkEKJJouJGRRkaLGbg", 46 + "did:key:zQ3shpKnbdPx3g3CmPf5cRVTPe1HtSwVn5ish3wSnDPQCbLJK" 47 + ], 48 + "verificationMethods": { 49 + "atproto": "did:key:zQ3shXjHeiBuRCKmM36cuYnm7YEMzhGnCmCyW92sRJ9pribSF" 50 + } 51 + }, 52 + "cid": "bafyreihmuvr3frdvd6vmdhucih277prdcfcezf67lasg5oekxoimnunjoq", 53 + "nullified": false, 54 + "createdAt": "2023-04-12T17:26:46.468Z" 55 + } 56 + ]
+125
didplc/testdata/log_legacy_dholms.json
··· 1 + [ 2 + { 3 + "did": "did:plc:yk4dd2qkboz2yv6tpubpc6co", 4 + "operation": { 5 + "sig": "7QTzqO1BcL3eDzP4P_YBxMmv5U4brHzAItkM9w5o8gZA7ElZkrVYEwsfQCfk5EoWLk58Z1y6fyNP9x1pthJnlw", 6 + "prev": null, 7 + "type": "create", 8 + "handle": "dan.bsky.social", 9 + "service": "https://bsky.social", 10 + "signingKey": "did:key:zQ3shP5TBe1sQfSttXty15FAEHV1DZgcxRZNxvEWnPfLFwLxJ", 11 + "recoveryKey": "did:key:zQ3shhCGUqDKjStzuDxPkTxN6ujddP4RkEKJJouJGRRkaLGbg" 12 + }, 13 + "cid": "bafyreigcxay6ucqlwowfpu35alyxqtv3c4vsj7gmdtmnidsnqs6nblyarq", 14 + "nullified": false, 15 + "createdAt": "2022-11-17T01:07:13.996Z" 16 + }, 17 + { 18 + "did": "did:plc:yk4dd2qkboz2yv6tpubpc6co", 19 + "operation": { 20 + "sig": "n-VWsPZY4xkFN8wlg-kJBU_yzWTNd2oBnbjkjxXu3HdjbBLaEB7K39JHIPn_DZVALKRjts6bUicjSEecZy8eIw", 21 + "prev": "bafyreigcxay6ucqlwowfpu35alyxqtv3c4vsj7gmdtmnidsnqs6nblyarq", 22 + "type": "plc_operation", 23 + "services": { 24 + "atproto_pds": { 25 + "type": "AtprotoPersonalDataServer", 26 + "endpoint": "https://bsky.social" 27 + } 28 + }, 29 + "alsoKnownAs": [ 30 + "at://dholms.xyz" 31 + ], 32 + "rotationKeys": [ 33 + "did:key:zQ3shhCGUqDKjStzuDxPkTxN6ujddP4RkEKJJouJGRRkaLGbg", 34 + "did:key:zQ3shP5TBe1sQfSttXty15FAEHV1DZgcxRZNxvEWnPfLFwLxJ" 35 + ], 36 + "verificationMethods": { 37 + "atproto": "did:key:zQ3shP5TBe1sQfSttXty15FAEHV1DZgcxRZNxvEWnPfLFwLxJ" 38 + } 39 + }, 40 + "cid": "bafyreiho5sanautvnw3det66jcwic4vkeabc35y7iou3ygwj2l3xqcxdau", 41 + "nullified": false, 42 + "createdAt": "2023-03-06T18:47:09.501Z" 43 + }, 44 + { 45 + "did": "did:plc:yk4dd2qkboz2yv6tpubpc6co", 46 + "operation": { 47 + "sig": "HWgrfQXxUN3mhR5TR-nrwGJwVr9RDbyDn6eCmqBg32x2zIjhe98YxOtFOLI9jQkBlTTzqzUOwJh1KZd4O2pDOw", 48 + "prev": "bafyreiho5sanautvnw3det66jcwic4vkeabc35y7iou3ygwj2l3xqcxdau", 49 + "type": "plc_operation", 50 + "services": { 51 + "atproto_pds": { 52 + "type": "AtprotoPersonalDataServer", 53 + "endpoint": "https://bsky.social" 54 + } 55 + }, 56 + "alsoKnownAs": [ 57 + "at://dholms.bsky.social" 58 + ], 59 + "rotationKeys": [ 60 + "did:key:zQ3shhCGUqDKjStzuDxPkTxN6ujddP4RkEKJJouJGRRkaLGbg", 61 + "did:key:zQ3shP5TBe1sQfSttXty15FAEHV1DZgcxRZNxvEWnPfLFwLxJ" 62 + ], 63 + "verificationMethods": { 64 + "atproto": "did:key:zQ3shP5TBe1sQfSttXty15FAEHV1DZgcxRZNxvEWnPfLFwLxJ" 65 + } 66 + }, 67 + "cid": "bafyreic3am2nmgykxtwsxwigzn6faibxv5ef5kalcv7li3eatcqldcqrku", 68 + "nullified": false, 69 + "createdAt": "2023-03-06T19:50:49.987Z" 70 + }, 71 + { 72 + "did": "did:plc:yk4dd2qkboz2yv6tpubpc6co", 73 + "operation": { 74 + "sig": "9Fy2iHCSK5mtgLNCkS9CyI0r7lu6H1SVgusaD1jQdsMUySUU6apde0z7SobpYZKp4sThk4hxOWtO-bXhu1cNjg", 75 + "prev": "bafyreic3am2nmgykxtwsxwigzn6faibxv5ef5kalcv7li3eatcqldcqrku", 76 + "type": "plc_operation", 77 + "services": { 78 + "atproto_pds": { 79 + "type": "AtprotoPersonalDataServer", 80 + "endpoint": "https://bsky.social" 81 + } 82 + }, 83 + "alsoKnownAs": [ 84 + "at://dholms.xyz" 85 + ], 86 + "rotationKeys": [ 87 + "did:key:zQ3shhCGUqDKjStzuDxPkTxN6ujddP4RkEKJJouJGRRkaLGbg", 88 + "did:key:zQ3shP5TBe1sQfSttXty15FAEHV1DZgcxRZNxvEWnPfLFwLxJ" 89 + ], 90 + "verificationMethods": { 91 + "atproto": "did:key:zQ3shP5TBe1sQfSttXty15FAEHV1DZgcxRZNxvEWnPfLFwLxJ" 92 + } 93 + }, 94 + "cid": "bafyreicwybxr6h6vkxpoarismso3liozdzswshmzcvl4tyckdazn5lxjte", 95 + "nullified": false, 96 + "createdAt": "2023-03-06T19:51:09.950Z" 97 + }, 98 + { 99 + "did": "did:plc:yk4dd2qkboz2yv6tpubpc6co", 100 + "operation": { 101 + "sig": "lBXd8rHZ84hCuQysGdi_5A9C8yPHTHasPibO4DZiuZVrehs2hiBcjAL0srLSTsF1kvsHTw1ddai-QwH0Wd_drQ", 102 + "prev": "bafyreicwybxr6h6vkxpoarismso3liozdzswshmzcvl4tyckdazn5lxjte", 103 + "type": "plc_operation", 104 + "services": { 105 + "atproto_pds": { 106 + "type": "AtprotoPersonalDataServer", 107 + "endpoint": "https://bsky.social" 108 + } 109 + }, 110 + "alsoKnownAs": [ 111 + "at://dholms.xyz" 112 + ], 113 + "rotationKeys": [ 114 + "did:key:zQ3shhCGUqDKjStzuDxPkTxN6ujddP4RkEKJJouJGRRkaLGbg", 115 + "did:key:zQ3shpKnbdPx3g3CmPf5cRVTPe1HtSwVn5ish3wSnDPQCbLJK" 116 + ], 117 + "verificationMethods": { 118 + "atproto": "did:key:zQ3shXjHeiBuRCKmM36cuYnm7YEMzhGnCmCyW92sRJ9pribSF" 119 + } 120 + }, 121 + "cid": "bafyreidfrpuegbqd5r56shka4duythb7phb6d7i3bck2dkeb5fjppwd7gi", 122 + "nullified": false, 123 + "createdAt": "2023-03-09T23:18:31.709Z" 124 + } 125 + ]