go scratch code for atproto

Compare changes

Choose any two refs to compare.

+212
atproto/auth/permission.go
···
··· 1 + package auth 2 + 3 + import ( 4 + "errors" 5 + "fmt" 6 + "net/url" 7 + "strconv" 8 + "strings" 9 + ) 10 + 11 + var ( 12 + ErrInvalidPermissionSyntax = errors.New("invalid permission syntax") 13 + ErrUnknownScope = errors.New("unknown scope type") 14 + ) 15 + 16 + type Permission struct { 17 + Type string `json:"type,omitempty"` 18 + Resource string `json:"resource"` 19 + 20 + // repo 21 + Collections []string `json:"collection,omitempty"` 22 + Action string `json:"action,omitempty"` 23 + 24 + // rpc 25 + Endpoints []string `json:"lxm,omitempty"` 26 + Audience string `json:"aud,omitempty"` 27 + 28 + // blob 29 + MaxSize *uint64 `json:"maxSize,omitempty"` 30 + Accept []string `json:"accept,omitempty"` 31 + 32 + // account 33 + Read []string `json:"read,omitempty"` 34 + Manage []string `json:"manage,omitempty"` 35 + 36 + // identity 37 + DID []string `json:"did,omitempty"` 38 + PLC []string `json:"plc,omitempty"` 39 + 40 + // include 41 + PermissionSet string `json:"permissionSet,omitempty"` 42 + } 43 + 44 + func (p *Permission) Scope() string { 45 + 46 + positional := "" 47 + params := make(url.Values) 48 + 49 + switch p.Resource { 50 + case "repo": 51 + if len(p.Collections) == 1 { 52 + positional = p.Collections[0] 53 + } else if len(p.Collections) > 1 { 54 + params["collection"] = p.Collections 55 + } 56 + if p.Action != "" { 57 + params.Set("action", p.Action) 58 + } 59 + case "rpc": 60 + if len(p.Endpoints) == 1 { 61 + positional = p.Endpoints[0] 62 + } else if len(p.Endpoints) > 1 { 63 + params["lxm"] = p.Endpoints 64 + } 65 + if p.Audience != "" { 66 + params.Set("aud", p.Audience) 67 + } 68 + case "blob": 69 + if p.MaxSize != nil { 70 + params.Set("maxSize", strconv.Itoa(int(*p.MaxSize))) 71 + } 72 + if len(p.Accept) == 1 { 73 + positional = p.Accept[0] 74 + } else if len(p.Accept) > 1 { 75 + params["accept"] = p.Accept 76 + } 77 + case "account": 78 + if len(p.Read) == 1 { 79 + positional = p.Read[0] 80 + } else if len(p.Read) > 1 { 81 + params["read"] = p.Read 82 + } 83 + if len(p.Manage) > 0 { 84 + params["manage"] = p.Manage 85 + } 86 + case "identity": 87 + if len(p.DID) == 1 { 88 + positional = p.DID[0] 89 + } else if len(p.DID) > 1 { 90 + params["did"] = p.DID 91 + } 92 + if len(p.PLC) > 0 { 93 + params["plc"] = p.PLC 94 + } 95 + case "include": 96 + if p.PermissionSet != "" { 97 + positional = p.PermissionSet 98 + } 99 + // TODO: other params... 100 + if p.Audience != "" { 101 + params.Set("aud", p.Audience) 102 + } 103 + default: 104 + return "" 105 + } 106 + 107 + scope := p.Resource 108 + if positional != "" { 109 + scope = scope + ":" + positional 110 + } 111 + if len(params) > 0 { 112 + scope = scope + "?" + params.Encode() 113 + } 114 + return scope 115 + } 116 + 117 + func ParseScope(scope string) (*Permission, error) { 118 + 119 + front, query, _ := strings.Cut(scope, "?") 120 + resource, positional, _ := strings.Cut(front, ":") 121 + 122 + params, err := url.ParseQuery(query) 123 + if err != nil { 124 + return nil, fmt.Errorf("%w: %w", ErrInvalidPermissionSyntax, err) 125 + } 126 + 127 + p := Permission{ 128 + Type: "permission", 129 + Resource: resource, 130 + } 131 + 132 + // TODO: should unknown fields be an error? 133 + // TODO: could pre-parse in all the various fields? and then just positional per type 134 + switch resource { 135 + case "repo": 136 + if params.Has("collection") { 137 + if positional != "" { 138 + return nil, ErrInvalidPermissionSyntax 139 + } 140 + p.Collections = params["collection"] 141 + } 142 + if positional != "" { 143 + p.Collections = []string{positional} 144 + } 145 + p.Action = params.Get("action") 146 + case "rpc": 147 + if params.Has("lxm") { 148 + if positional != "" { 149 + return nil, ErrInvalidPermissionSyntax 150 + } 151 + p.Endpoints = params["lxm"] 152 + } 153 + if positional != "" { 154 + p.Endpoints = []string{positional} 155 + } 156 + p.Audience = params.Get("aud") 157 + case "blob": 158 + if params.Has("accept") { 159 + if positional != "" { 160 + return nil, ErrInvalidPermissionSyntax 161 + } 162 + p.Accept = params["accept"] 163 + } 164 + if positional != "" { 165 + p.Accept = []string{positional} 166 + } 167 + if params.Has("maxSize") { 168 + v, err := strconv.ParseUint(params.Get("maxSize"), 10, 64) 169 + if err != nil { 170 + return nil, fmt.Errorf("%w: %w", ErrInvalidPermissionSyntax, err) 171 + } 172 + p.MaxSize = &v 173 + } 174 + case "account": 175 + if params.Has("read") { 176 + if positional != "" { 177 + return nil, ErrInvalidPermissionSyntax 178 + } 179 + p.Read = params["read"] 180 + } 181 + if positional != "" { 182 + p.Read = []string{positional} 183 + } 184 + p.Manage = params["manage"] 185 + case "identity": 186 + if params.Has("did") { 187 + if positional != "" { 188 + return nil, ErrInvalidPermissionSyntax 189 + } 190 + p.DID = params["did"] 191 + } 192 + if positional != "" { 193 + p.DID = []string{positional} 194 + } 195 + p.PLC = params["plc"] 196 + case "include": 197 + if params.Has("permissionSet") { 198 + if positional != "" { 199 + return nil, ErrInvalidPermissionSyntax 200 + } 201 + p.PermissionSet = params.Get("permissionSet") 202 + } 203 + if positional != "" { 204 + p.PermissionSet = positional 205 + } 206 + // TODO: also parse most other params... 207 + p.Audience = params.Get("aud") 208 + default: 209 + return nil, ErrUnknownScope 210 + } 211 + return &p, nil 212 + }
+99
atproto/auth/permission_test.go
···
··· 1 + package auth 2 + 3 + import ( 4 + "bufio" 5 + "fmt" 6 + "os" 7 + "testing" 8 + 9 + "github.com/stretchr/testify/assert" 10 + ) 11 + 12 + func TestRoundTrip(t *testing.T) { 13 + assert := assert.New(t) 14 + 15 + // NOTE: this escapes colons and slashes, which aren't strictly necessary 16 + testScopes := []string{ 17 + "repo:com.example.record?action=all", 18 + "repo?action=all&collection=com.example.record&collection=com.example.other", 19 + "rpc:com.example.query?aud=did%3Aweb%3Aapi.example.com%23frag", 20 + "rpc?aud=did%3Aweb%3Aapi.example.com%23frag&lxm=com.example.query&lxm=com.example.procedure", 21 + "blob:image/*", 22 + "blob?accept=image%2Fpng&accept=image%2Fjpeg&maxSize=123", 23 + "account:email?manage=deactivate", 24 + "identity:handle?plc=rotation", 25 + "include:app.example.authBasics", 26 + } 27 + 28 + for _, scope := range testScopes { 29 + p, err := ParseScope(scope) 30 + assert.NoError(err) 31 + if err != nil { 32 + continue 33 + } 34 + assert.Equal(scope, p.Scope()) 35 + } 36 + } 37 + 38 + func TestInteropPermissionValid(t *testing.T) { 39 + assert := assert.New(t) 40 + file, err := os.Open("testdata/permission_scopes_valid.txt") 41 + assert.NoError(err) 42 + defer file.Close() 43 + scanner := bufio.NewScanner(file) 44 + for scanner.Scan() { 45 + line := scanner.Text() 46 + if len(line) == 0 || line[0] == '#' { 47 + continue 48 + } 49 + p, err := ParseScope(line) 50 + if err != nil { 51 + fmt.Println("BAD: " + line) 52 + } 53 + assert.NoError(err) 54 + if p != nil { 55 + assert.False(p.Scope() == "") 56 + } 57 + } 58 + assert.NoError(scanner.Err()) 59 + } 60 + 61 + func TestInteropPermissionInvalid(t *testing.T) { 62 + assert := assert.New(t) 63 + file, err := os.Open("testdata/permission_scopes_invalid.txt") 64 + assert.NoError(err) 65 + defer file.Close() 66 + scanner := bufio.NewScanner(file) 67 + for scanner.Scan() { 68 + line := scanner.Text() 69 + if len(line) == 0 || line[0] == '#' { 70 + continue 71 + } 72 + _, err := ParseScope(line) 73 + if err == nil { 74 + fmt.Println("BAD: " + line) 75 + } 76 + assert.Error(err) 77 + } 78 + assert.NoError(scanner.Err()) 79 + } 80 + 81 + func TestInteropPermissionOther(t *testing.T) { 82 + assert := assert.New(t) 83 + file, err := os.Open("testdata/permission_scopes_other.txt") 84 + assert.NoError(err) 85 + defer file.Close() 86 + scanner := bufio.NewScanner(file) 87 + for scanner.Scan() { 88 + line := scanner.Text() 89 + if len(line) == 0 || line[0] == '#' { 90 + continue 91 + } 92 + _, err := ParseScope(line) 93 + if err == nil { 94 + fmt.Println("BAD: " + line) 95 + } 96 + assert.Error(err) 97 + } 98 + assert.NoError(scanner.Err()) 99 + }
+13
atproto/auth/testdata/permission_scopes_invalid.txt
···
··· 1 + 2 + blob:image/png?maxSize=-123 3 + blob:image/png?maxSize=blah 4 + blob:image/png?maxSize 5 + blob:image/png?maxSize=123?maxSize=123 6 + 7 + # TODO: these partial strings 8 + #repo:123 9 + #repo 10 + #repo: 11 + #rpc:123 12 + #rpc 13 + #rpc:com.example.query?aud=api.example.com
+3
atproto/auth/testdata/permission_scopes_other.txt
···
··· 1 + atproto 2 + blah 3 + unknown:resource?type=true
+14
atproto/auth/testdata/permission_scopes_valid.txt
···
··· 1 + repo:com.example.record 2 + repo:com.example.record?action=* 3 + repo:* 4 + repo?action=all&collection=com.example.record&collection=com.example.other 5 + 6 + rpc:com.example.query?aud=did:web:api.example.com%23api_example 7 + rpc?aud=did%3Aweb%3Aapi.example.com%23frag&lxm=com.example.query&lxm=com.example.procedure 8 + 9 + blob:image/*?maxSize=2000 10 + blob?accept=image%2Fpng&accept=image%2Fjpeg&maxSize=123 11 + 12 + account:email?manage=deactivate 13 + identity:handle?plc=rotation 14 + include:app.example.authBasics
+17
atproto/heap/cid.go
···
··· 1 + package heap 2 + 3 + import ( 4 + "github.com/ipfs/go-cid" 5 + "github.com/multiformats/go-multihash" 6 + ) 7 + 8 + func computeCID(b []byte) (*cid.Cid, error) { 9 + // TODO: not sure why this would ever fail; could we ignore or panic? 10 + // TODO: is there a more performant way to call SHA256, then wrap? 11 + builder := cid.NewPrefixV1(cid.Raw, multihash.SHA2_256) 12 + c, err := builder.Sum(b) 13 + if err != nil { 14 + return nil, err 15 + } 16 + return &c, err 17 + }
+108
atproto/heap/examples_test.go
···
··· 1 + package heap 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "fmt" 8 + 9 + "github.com/bluesky-social/indigo/atproto/repo" 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + ) 12 + 13 + func ExampleNetClient_GetRepoCAR() { 14 + 15 + ctx := context.Background() 16 + nc := NewNetClient() 17 + did := syntax.DID("did:plc:ewvi7nxzyoun6zhxrhs64oiz") 18 + 19 + stream, err := nc.GetRepoCAR(ctx, did) 20 + if err != nil { 21 + panic("failed to download CAR: " + err.Error()) 22 + } 23 + defer stream.Close() 24 + 25 + // NOTE: could also use LoadCommitFromCAR 26 + commit, _, err := repo.LoadRepoFromCAR(ctx, stream) 27 + if err != nil { 28 + panic("failed to parse CAR: " + err.Error()) 29 + } 30 + 31 + ident, _ := nc.Dir.LookupDID(ctx, did) 32 + pub, _ := ident.PublicKey() 33 + 34 + if err := commit.VerifySignature(pub); err != nil { 35 + panic("failed to verify commit signature: " + err.Error()) 36 + } 37 + 38 + fmt.Println(commit.DID) 39 + // did:plc:ewvi7nxzyoun6zhxrhs64oiz 40 + } 41 + 42 + func ExampleNetClient_GetBlob() { 43 + 44 + ctx := context.Background() 45 + nc := NewNetClient() 46 + did := syntax.DID("did:plc:ewvi7nxzyoun6zhxrhs64oiz") 47 + cid := syntax.CID("bafkreieya7iitpu4okjtm7iexiwikj7t63ttlthad32ojsvjqhqbc3iwmi") 48 + 49 + buf := bytes.Buffer{} 50 + if err := nc.GetBlob(ctx, did, cid, &buf); err != nil { 51 + panic("failed to download blob: " + err.Error()) 52 + } 53 + 54 + fmt.Println(buf.Len()) 55 + // 518394 56 + } 57 + 58 + func ExampleNetClient_GetAccountStatus() { 59 + 60 + ctx := context.Background() 61 + nc := NewNetClient() 62 + did := syntax.DID("did:plc:ewvi7nxzyoun6zhxrhs64oiz") 63 + 64 + active, status, err := nc.GetAccountStatus(ctx, did) 65 + if err != nil { 66 + panic("failed to check account status: " + err.Error()) 67 + } 68 + 69 + fmt.Printf("active=%t status=%s\n", active, status) 70 + // active=true status= 71 + } 72 + 73 + func ExampleNetClient_GetRecordUnverified() { 74 + 75 + ctx := context.Background() 76 + nc := NewNetClient() 77 + did := syntax.DID("did:plc:ewvi7nxzyoun6zhxrhs64oiz") 78 + collection := syntax.NSID("app.bsky.actor.profile") 79 + rkey := syntax.RecordKey("self") 80 + 81 + raw, _, err := nc.GetRecordUnverified(ctx, did, collection, rkey) 82 + if err != nil { 83 + panic("failed to fetch record: " + err.Error()) 84 + } 85 + var record map[string]any 86 + _ = json.Unmarshal(*raw, &record) 87 + 88 + fmt.Println(record["displayName"]) 89 + // AT Protocol Developers 90 + } 91 + 92 + func ExampleNetClient_GetRecord() { 93 + 94 + ctx := context.Background() 95 + nc := NewNetClient() 96 + did := syntax.DID("did:plc:ewvi7nxzyoun6zhxrhs64oiz") 97 + collection := syntax.NSID("app.bsky.actor.profile") 98 + rkey := syntax.RecordKey("self") 99 + 100 + var record map[string]any 101 + _, err := nc.GetRecord(ctx, did, collection, rkey, &record) 102 + if err != nil { 103 + panic("failed to fetch record: " + err.Error()) 104 + } 105 + 106 + fmt.Println(record["displayName"]) 107 + // Output: AT Protocol Developers 108 + }
+178
atproto/heap/netclient.go
···
··· 1 + package heap 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "errors" 8 + "fmt" 9 + "io" 10 + "log/slog" 11 + "net/http" 12 + 13 + "github.com/bluesky-social/indigo/atproto/identity" 14 + "github.com/bluesky-social/indigo/atproto/syntax" 15 + ) 16 + 17 + type NetClient struct { 18 + Client *http.Client 19 + // NOTE: maybe should use a "resolver" which doesn't do handle resolution? or leave that to calling code to configure 20 + Dir identity.Directory 21 + UserAgent string 22 + } 23 + 24 + func NewNetClient() *NetClient { 25 + return &NetClient{ 26 + // TODO: maybe custom client: SSRF, retries, timeout 27 + Client: http.DefaultClient, 28 + Dir: identity.DefaultDirectory(), 29 + UserAgent: "cobalt-netclient", 30 + } 31 + } 32 + 33 + // Fetches repo export (CAR file). Calling code is responsible for closing the returned [io.ReadCloser] on success (often an HTTP response body). Does not verify signatures or CAR format or structure in any way. 34 + func (nc *NetClient) GetRepoCAR(ctx context.Context, did syntax.DID) (io.ReadCloser, error) { 35 + ident, err := nc.Dir.LookupDID(ctx, did) 36 + if err != nil { 37 + return nil, err 38 + } 39 + host := ident.PDSEndpoint() 40 + if host == "" { 41 + return nil, fmt.Errorf("account has no PDS host registered: %s", did.String()) 42 + } 43 + // TODO: validate host 44 + // TODO: DID escaping (?) 45 + u := fmt.Sprintf("%s/xrpc/com.atproto.sync.getRepo?did=%s", host, did) 46 + 47 + slog.Debug("downloading repo CAR", "did", did, "url", u) 48 + req, err := http.NewRequestWithContext(ctx, "GET", u, nil) 49 + if err != nil { 50 + return nil, err 51 + } 52 + if nc.UserAgent != "" { 53 + req.Header.Set("User-Agent", nc.UserAgent) 54 + } 55 + req.Header.Set("Accept", "application/vnd.ipld.car") 56 + 57 + resp, err := nc.Client.Do(req) 58 + if err != nil { 59 + return nil, fmt.Errorf("fetching repo CAR file (%s): %w", did, err) 60 + } 61 + 62 + if resp.StatusCode != http.StatusOK { 63 + resp.Body.Close() 64 + return nil, fmt.Errorf("HTTP error fetching repo CAR file (%s): %d", did, resp.StatusCode) 65 + } 66 + 67 + return resp.Body, nil 68 + } 69 + 70 + // Resolves and fetches blob from the network. Calling code must close the returned [io.ReadCloser] (eg, HTTP response body). Does not verify CID. 71 + func (nc *NetClient) GetBlobReader(ctx context.Context, did syntax.DID, cid syntax.CID) (io.ReadCloser, error) { 72 + ident, err := nc.Dir.LookupDID(ctx, did) 73 + if err != nil { 74 + return nil, err 75 + } 76 + host := ident.PDSEndpoint() 77 + if host == "" { 78 + return nil, fmt.Errorf("account has no PDS host registered: %s", did.String()) 79 + } 80 + // TODO: validate host 81 + // TODO: DID escaping (?) 82 + u := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", host, did, cid) 83 + 84 + slog.Debug("downloading blob", "did", did, "cid", cid, "url", u) 85 + req, err := http.NewRequestWithContext(ctx, "GET", u, nil) 86 + if err != nil { 87 + return nil, err 88 + } 89 + if nc.UserAgent != "" { 90 + req.Header.Set("User-Agent", nc.UserAgent) 91 + } 92 + req.Header.Set("Accept", "*/*") 93 + 94 + resp, err := nc.Client.Do(req) 95 + if err != nil { 96 + return nil, fmt.Errorf("fetching blob (%s, %s): %w", did, cid, err) 97 + } 98 + 99 + if resp.StatusCode != http.StatusOK { 100 + resp.Body.Close() 101 + return nil, fmt.Errorf("HTTP error fetching blob (%s, %s): %d", did, cid, resp.StatusCode) 102 + } 103 + 104 + return resp.Body, nil 105 + } 106 + 107 + var ErrMismatchedBlobCID = errors.New("mismatched blob CID") 108 + 109 + // Fetches blob, writes in to provided buffer, and verified CID hash. 110 + func (nc *NetClient) GetBlob(ctx context.Context, did syntax.DID, cid syntax.CID, buf *bytes.Buffer) error { 111 + stream, err := nc.GetBlobReader(ctx, did, cid) 112 + if err != nil { 113 + return err 114 + } 115 + defer stream.Close() 116 + 117 + if _, err := io.Copy(buf, stream); err != nil { 118 + return err 119 + } 120 + 121 + c, err := computeCID(buf.Bytes()) 122 + if err != nil { 123 + return err 124 + } 125 + 126 + if c.String() != cid.String() { 127 + return ErrMismatchedBlobCID 128 + } 129 + return nil 130 + } 131 + 132 + type repoStatusResp struct { 133 + Active bool `json:"active"` 134 + DID string `json:"did"` 135 + Status string `json:"status,omitempty"` 136 + } 137 + 138 + // Fetches account status. Returns a boolean indicating active state, and a string describing any non-active status. 139 + func (nc *NetClient) GetAccountStatus(ctx context.Context, did syntax.DID) (active bool, status string, err error) { 140 + ident, err := nc.Dir.LookupDID(ctx, did) 141 + if err != nil { 142 + return false, "", err 143 + } 144 + host := ident.PDSEndpoint() 145 + if host == "" { 146 + return false, "", fmt.Errorf("account has no PDS host registered: %s", did.String()) 147 + } 148 + // TODO: validate host 149 + // TODO: DID escaping (?) 150 + u := fmt.Sprintf("%s/xrpc/com.atproto.sync.getRepoStatus?did=%s", host, did) 151 + 152 + slog.Debug("fetching account status", "did", did, "url", u) 153 + req, err := http.NewRequestWithContext(ctx, "GET", u, nil) 154 + if err != nil { 155 + return false, "", err 156 + } 157 + if nc.UserAgent != "" { 158 + req.Header.Set("User-Agent", nc.UserAgent) 159 + } 160 + req.Header.Set("Accept", "application/json") 161 + 162 + resp, err := nc.Client.Do(req) 163 + if err != nil { 164 + return false, "", fmt.Errorf("fetching account status (%s): %w", did, err) 165 + } 166 + defer resp.Body.Close() 167 + 168 + if resp.StatusCode != http.StatusOK { 169 + return false, "", fmt.Errorf("HTTP error fetching account status (%s): %d", did, resp.StatusCode) 170 + } 171 + 172 + var rsr repoStatusResp 173 + if err := json.NewDecoder(resp.Body).Decode(&rsr); err != nil { 174 + return false, "", fmt.Errorf("failed decoding account status response: %w", err) 175 + } 176 + 177 + return rsr.Active, rsr.Status, nil 178 + }
+156
atproto/heap/record.go
···
··· 1 + package heap 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "fmt" 8 + "log/slog" 9 + "net/http" 10 + 11 + "github.com/bluesky-social/indigo/atproto/data" 12 + "github.com/bluesky-social/indigo/atproto/repo" 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + ) 15 + 16 + type repoRecordResp struct { 17 + URI string `json:"uri"` 18 + CID syntax.CID `json:"cid"` 19 + Value json.RawMessage `json:"value"` 20 + } 21 + 22 + // Fetches record JSON using com.atproto.repo.getRecord, and returns record as [json.RawMessage] and the CID (as string). 23 + func (nc *NetClient) GetRecordUnverified(ctx context.Context, did syntax.DID, collection syntax.NSID, rkey syntax.RecordKey) (*json.RawMessage, syntax.CID, error) { 24 + ident, err := nc.Dir.LookupDID(ctx, did) 25 + if err != nil { 26 + return nil, "", err 27 + } 28 + host := ident.PDSEndpoint() 29 + if host == "" { 30 + return nil, "", fmt.Errorf("account has no PDS host registered: %s", did.String()) 31 + } 32 + // TODO: validate host 33 + // TODO: DID escaping (?) 34 + u := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", host, did, collection, rkey) 35 + 36 + slog.Debug("fetching record JSON", "did", did, "url", u) 37 + req, err := http.NewRequestWithContext(ctx, "GET", u, nil) 38 + if err != nil { 39 + return nil, "", err 40 + } 41 + if nc.UserAgent != "" { 42 + req.Header.Set("User-Agent", nc.UserAgent) 43 + } 44 + req.Header.Set("Accept", "application/json") 45 + 46 + resp, err := nc.Client.Do(req) 47 + if err != nil { 48 + return nil, "", fmt.Errorf("fetching record JSON (%s): %w", did, err) 49 + } 50 + defer resp.Body.Close() 51 + 52 + if resp.StatusCode != http.StatusOK { 53 + return nil, "", fmt.Errorf("HTTP error fetching record JSON (%s): %d", did, resp.StatusCode) 54 + } 55 + 56 + var rrr repoRecordResp 57 + if err := json.NewDecoder(resp.Body).Decode(&rrr); err != nil { 58 + return nil, "", fmt.Errorf("failed decoding account status response: %w", err) 59 + } 60 + 61 + return &rrr.Value, rrr.CID, nil 62 + } 63 + 64 + // Fetches a record "proof" using com.atproto.sync.getRecord. Verifies signature and merkel chain. Copies record content in out 'out' parameter. 65 + // 66 + // If out is nil, record data is not returned. If it is [bytes.Buffer], the record CBOR is copied in. Otherwise, the record is transformed to JSON and Unmarshalled in to provided output, which could be a pointer to a struct, [json.RawMessage], `map[string]any`, etc. 67 + // 68 + // TODO: this might not be fully validating MST tree and record CID hashes or encoding yet 69 + func (nc *NetClient) GetRecord(ctx context.Context, did syntax.DID, collection syntax.NSID, rkey syntax.RecordKey, out any) (syntax.CID, error) { 70 + // TODO: "GetRecordProof" variant, which just returns CAR as io.ReadCloser? 71 + ident, err := nc.Dir.LookupDID(ctx, did) 72 + if err != nil { 73 + return "", err 74 + } 75 + pub, err := ident.PublicKey() 76 + if err != nil { 77 + return "", err 78 + } 79 + host := ident.PDSEndpoint() 80 + if host == "" { 81 + return "", fmt.Errorf("account has no PDS host registered: %s", did.String()) 82 + } 83 + // TODO: validate host 84 + // TODO: DID escaping (?) 85 + u := fmt.Sprintf("%s/xrpc/com.atproto.sync.getRecord?did=%s&collection=%s&rkey=%s", host, did, collection, rkey) 86 + 87 + slog.Debug("fetching record proof", "did", did, "url", u) 88 + req, err := http.NewRequestWithContext(ctx, "GET", u, nil) 89 + if err != nil { 90 + return "", err 91 + } 92 + if nc.UserAgent != "" { 93 + req.Header.Set("User-Agent", nc.UserAgent) 94 + } 95 + req.Header.Set("Accept", "application/vnd.ipld.car") 96 + 97 + resp, err := nc.Client.Do(req) 98 + if err != nil { 99 + return "", fmt.Errorf("fetching record proof (%s): %w", did, err) 100 + } 101 + defer resp.Body.Close() 102 + 103 + if resp.StatusCode != http.StatusOK { 104 + return "", fmt.Errorf("HTTP error fetching record proof (%s): %d", did, resp.StatusCode) 105 + } 106 + 107 + // TODO: re-confirm if loading tree re-checks all CIDs; or if we need to re-compute the tree data CID 108 + commit, rp, err := repo.LoadRepoFromCAR(ctx, resp.Body) 109 + if err != nil { 110 + return "", fmt.Errorf("failed to parse record proof CAR (%s): %w", did, err) 111 + } 112 + 113 + // NOTE: LoadRepoFromCAR calls commit.VerifyStructure() internally 114 + 115 + if err := commit.VerifySignature(pub); err != nil { 116 + return "", fmt.Errorf("failed to verify record proof signature (%s): %w", did, err) 117 + } 118 + 119 + rbytes, rcid, err := rp.GetRecordBytes(ctx, collection, rkey) 120 + if err != nil { 121 + return "", fmt.Errorf("failed to read record from proof CAR (%s): %w", did, err) 122 + } 123 + cidStr := syntax.CID(rcid.String()) 124 + 125 + // TODO: `GetRecordBytes` does not currently verify record CID, but unpacking CAR file should have done that? but need to confirm CAR implementation does this 126 + 127 + // check that record CBOR is valid, even if we don't return it 128 + rdata, err := data.UnmarshalCBOR(rbytes) 129 + if err != nil { 130 + return "", fmt.Errorf("failed to parse record CBOR (%s): %w", did, err) 131 + } 132 + 133 + switch out := out.(type) { 134 + case nil: 135 + // if output isn't captured, bail out early 136 + return cidStr, nil 137 + case *bytes.Buffer: 138 + // simply copy data over 139 + out.Reset() 140 + _, err := out.Write(rbytes) 141 + if err != nil { 142 + return "", err 143 + } 144 + return cidStr, nil 145 + default: 146 + // attempt to unmarshal from json 147 + jsonBytes, err := json.Marshal(rdata) 148 + if err != nil { 149 + return "", err 150 + } 151 + if err := json.Unmarshal(jsonBytes, out); err != nil { 152 + return "", fmt.Errorf("failed unmarhsaling record (%s): %w", did, err) 153 + } 154 + return cidStr, nil 155 + } 156 + }
-17
atproto/netclient/cid.go
··· 1 - package netclient 2 - 3 - import ( 4 - "github.com/ipfs/go-cid" 5 - "github.com/multiformats/go-multihash" 6 - ) 7 - 8 - func computeCID(b []byte) (*cid.Cid, error) { 9 - // TODO: not sure why this would ever fail; could we ignore or panic? 10 - // TODO: is there a more performant way to call SHA256, then wrap? 11 - builder := cid.NewPrefixV1(cid.Raw, multihash.SHA2_256) 12 - c, err := builder.Sum(b) 13 - if err != nil { 14 - return nil, err 15 - } 16 - return &c, err 17 - }
···
-70
atproto/netclient/examples_test.go
··· 1 - package netclient 2 - 3 - import ( 4 - "bytes" 5 - "context" 6 - "fmt" 7 - 8 - "github.com/bluesky-social/indigo/atproto/repo" 9 - "github.com/bluesky-social/indigo/atproto/syntax" 10 - ) 11 - 12 - func ExampleNetClient_GetRepoCAR() { 13 - 14 - ctx := context.Background() 15 - nc := NewNetClient() 16 - did := syntax.DID("did:plc:ewvi7nxzyoun6zhxrhs64oiz") 17 - 18 - stream, err := nc.GetRepoCAR(ctx, did) 19 - if err != nil { 20 - panic("failed to download CAR: " + err.Error()) 21 - } 22 - defer stream.Close() 23 - 24 - // NOTE: could also use LoadCommitFromCAR 25 - commit, _, err := repo.LoadRepoFromCAR(ctx, stream) 26 - if err != nil { 27 - panic("failed to parse CAR: " + err.Error()) 28 - } 29 - 30 - ident, _ := nc.Dir.LookupDID(ctx, did) 31 - pub, _ := ident.PublicKey() 32 - 33 - if err := commit.VerifySignature(pub); err != nil { 34 - panic("failed to verify commit signature: " + err.Error()) 35 - } 36 - 37 - fmt.Println(commit.DID) 38 - // did:plc:ewvi7nxzyoun6zhxrhs64oiz 39 - } 40 - 41 - func ExampleNetClient_GetBlob() { 42 - 43 - ctx := context.Background() 44 - nc := NewNetClient() 45 - did := syntax.DID("did:plc:ewvi7nxzyoun6zhxrhs64oiz") 46 - cid := syntax.CID("bafkreieya7iitpu4okjtm7iexiwikj7t63ttlthad32ojsvjqhqbc3iwmi") 47 - 48 - buf := bytes.Buffer{} 49 - if err := nc.GetBlob(ctx, did, cid, &buf); err != nil { 50 - panic("failed to download blob: " + err.Error()) 51 - } 52 - 53 - fmt.Println(buf.Len()) 54 - // 518394 55 - } 56 - 57 - func ExampleNetClient_GetAccountStatus() { 58 - 59 - ctx := context.Background() 60 - nc := NewNetClient() 61 - did := syntax.DID("did:plc:ewvi7nxzyoun6zhxrhs64oiz") 62 - 63 - active, status, err := nc.GetAccountStatus(ctx, did) 64 - if err != nil { 65 - panic("failed to check account status: " + err.Error()) 66 - } 67 - 68 - fmt.Printf("active=%t status=%s\n", active, status) 69 - // Output: active=true status= 70 - }
···
-178
atproto/netclient/netclient.go
··· 1 - package netclient 2 - 3 - import ( 4 - "bytes" 5 - "context" 6 - "encoding/json" 7 - "errors" 8 - "fmt" 9 - "io" 10 - "log/slog" 11 - "net/http" 12 - 13 - "github.com/bluesky-social/indigo/atproto/identity" 14 - "github.com/bluesky-social/indigo/atproto/syntax" 15 - ) 16 - 17 - type NetClient struct { 18 - Client *http.Client 19 - // NOTE: maybe should use a "resolver" which doesn't do handle resolution? or leave that to calling code to configure 20 - Dir identity.Directory 21 - UserAgent string 22 - } 23 - 24 - func NewNetClient() *NetClient { 25 - return &NetClient{ 26 - // TODO: maybe custom client: SSRF, retries, timeout 27 - Client: http.DefaultClient, 28 - Dir: identity.DefaultDirectory(), 29 - UserAgent: "cobalt-netclient", 30 - } 31 - } 32 - 33 - // Fetches repo export (CAR file). Calling code is responsible for closing the returned [io.ReadCloser] on success (often an HTTP response body). Does not verify signatures or CAR format or structure in any way. 34 - func (nc *NetClient) GetRepoCAR(ctx context.Context, did syntax.DID) (io.ReadCloser, error) { 35 - ident, err := nc.Dir.LookupDID(ctx, did) 36 - if err != nil { 37 - return nil, err 38 - } 39 - host := ident.PDSEndpoint() 40 - if host == "" { 41 - return nil, fmt.Errorf("account has no PDS host registered: %s", did.String()) 42 - } 43 - // TODO: validate host 44 - // TODO: DID escaping (?) 45 - u := fmt.Sprintf("%s/xrpc/com.atproto.sync.getRepo?did=%s", host, did) 46 - 47 - slog.Debug("downloading repo CAR", "did", did, "url", u) 48 - req, err := http.NewRequestWithContext(ctx, "GET", u, nil) 49 - if err != nil { 50 - return nil, err 51 - } 52 - if nc.UserAgent != "" { 53 - req.Header.Set("User-Agent", nc.UserAgent) 54 - } 55 - req.Header.Set("Accept", "application/vnd.ipld.car") 56 - 57 - resp, err := nc.Client.Do(req) 58 - if err != nil { 59 - return nil, fmt.Errorf("fetching repo CAR file (%s): %w", did, err) 60 - } 61 - 62 - if resp.StatusCode != http.StatusOK { 63 - resp.Body.Close() 64 - return nil, fmt.Errorf("HTTP error fetching repo CAR file (%s): %d", did, resp.StatusCode) 65 - } 66 - 67 - return resp.Body, nil 68 - } 69 - 70 - // Resolves and fetches blob from the network. Calling code must close the returned [io.ReadCloser] (eg, HTTP response body). Does not verify CID. 71 - func (nc *NetClient) GetBlobReader(ctx context.Context, did syntax.DID, cid syntax.CID) (io.ReadCloser, error) { 72 - ident, err := nc.Dir.LookupDID(ctx, did) 73 - if err != nil { 74 - return nil, err 75 - } 76 - host := ident.PDSEndpoint() 77 - if host == "" { 78 - return nil, fmt.Errorf("account has no PDS host registered: %s", did.String()) 79 - } 80 - // TODO: validate host 81 - // TODO: DID escaping (?) 82 - u := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", host, did, cid) 83 - 84 - slog.Debug("downloading blob", "did", did, "cid", cid, "url", u) 85 - req, err := http.NewRequestWithContext(ctx, "GET", u, nil) 86 - if err != nil { 87 - return nil, err 88 - } 89 - if nc.UserAgent != "" { 90 - req.Header.Set("User-Agent", nc.UserAgent) 91 - } 92 - req.Header.Set("Accept", "*/*") 93 - 94 - resp, err := nc.Client.Do(req) 95 - if err != nil { 96 - return nil, fmt.Errorf("fetching blob (%s, %s): %w", did, cid, err) 97 - } 98 - 99 - if resp.StatusCode != http.StatusOK { 100 - resp.Body.Close() 101 - return nil, fmt.Errorf("HTTP error fetching blob (%s, %s): %d", did, cid, resp.StatusCode) 102 - } 103 - 104 - return resp.Body, nil 105 - } 106 - 107 - var ErrMismatchedBlobCID = errors.New("mismatched blob CID") 108 - 109 - // Fetches blob, writes in to provided buffer, and verified CID hash. 110 - func (nc *NetClient) GetBlob(ctx context.Context, did syntax.DID, cid syntax.CID, buf *bytes.Buffer) error { 111 - stream, err := nc.GetBlobReader(ctx, did, cid) 112 - if err != nil { 113 - return err 114 - } 115 - defer stream.Close() 116 - 117 - if _, err := io.Copy(buf, stream); err != nil { 118 - return err 119 - } 120 - 121 - c, err := computeCID(buf.Bytes()) 122 - if err != nil { 123 - return err 124 - } 125 - 126 - if c.String() != cid.String() { 127 - return ErrMismatchedBlobCID 128 - } 129 - return nil 130 - } 131 - 132 - type repoStatusResp struct { 133 - Active bool `json:"active"` 134 - DID string `json:"did"` 135 - Status string `json:"status,omitempty"` 136 - } 137 - 138 - // Fetches account status. Returns a boolean indicating active state, and a string describing any non-active status. 139 - func (nc *NetClient) GetAccountStatus(ctx context.Context, did syntax.DID) (active bool, status string, err error) { 140 - ident, err := nc.Dir.LookupDID(ctx, did) 141 - if err != nil { 142 - return false, "", err 143 - } 144 - host := ident.PDSEndpoint() 145 - if host == "" { 146 - return false, "", fmt.Errorf("account has no PDS host registered: %s", did.String()) 147 - } 148 - // TODO: validate host 149 - // TODO: DID escaping (?) 150 - u := fmt.Sprintf("%s/xrpc/com.atproto.sync.getRepoStatus?did=%s", host, did) 151 - 152 - slog.Debug("fetching account status", "did", did, "url", u) 153 - req, err := http.NewRequestWithContext(ctx, "GET", u, nil) 154 - if err != nil { 155 - return false, "", err 156 - } 157 - if nc.UserAgent != "" { 158 - req.Header.Set("User-Agent", nc.UserAgent) 159 - } 160 - req.Header.Set("Accept", "application/json") 161 - 162 - resp, err := nc.Client.Do(req) 163 - if err != nil { 164 - return false, "", fmt.Errorf("fetching account status (%s): %w", did, err) 165 - } 166 - defer resp.Body.Close() 167 - 168 - if resp.StatusCode != http.StatusOK { 169 - return false, "", fmt.Errorf("HTTP error fetching account status (%s): %d", did, resp.StatusCode) 170 - } 171 - 172 - var rsr repoStatusResp 173 - if err := json.NewDecoder(resp.Body).Decode(&rsr); err != nil { 174 - return false, "", fmt.Errorf("failed decoding account status response: %w", err) 175 - } 176 - 177 - return rsr.Active, rsr.Status, nil 178 - }
···
+283
backfill/consumer.go
···
··· 1 + package backfill 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "fmt" 7 + "log/slog" 8 + "net/http" 9 + "net/url" 10 + "strings" 11 + 12 + comatproto "github.com/bluesky-social/indigo/api/atproto" 13 + "github.com/bluesky-social/indigo/atproto/identity" 14 + "github.com/bluesky-social/indigo/atproto/repo" 15 + "github.com/bluesky-social/indigo/atproto/syntax" 16 + "github.com/bluesky-social/indigo/events" 17 + "github.com/bluesky-social/indigo/events/schedulers/parallel" 18 + 19 + "github.com/gorilla/websocket" 20 + ) 21 + 22 + type Backfiller struct { 23 + Dir identity.Directory 24 + Logger slog.Logger 25 + 26 + // TODO: 27 + CollectionFilter []string 28 + } 29 + 30 + func (bf *Backfiller) runConsumer() error { 31 + ctx := context.Background() 32 + 33 + // XXX 34 + relayHost := "https://bsky.network" 35 + cursor := "" 36 + userAgent := "cobalt-backfill" 37 + 38 + dialer := websocket.DefaultDialer 39 + u, err := url.Parse(relayHost) 40 + if err != nil { 41 + return fmt.Errorf("invalid relayHost URI: %w", err) 42 + } 43 + switch u.Scheme { 44 + case "http": 45 + u.Scheme = "ws" 46 + case "https": 47 + u.Scheme = "wss" 48 + } 49 + u.Path = "xrpc/com.atproto.sync.subscribeRepos" 50 + if cursor != "" { 51 + u.RawQuery = "cursor=" + cursor 52 + } 53 + urlString := u.String() 54 + con, _, err := dialer.Dial(urlString, http.Header{ 55 + "User-Agent": []string{userAgent}, 56 + }) 57 + if err != nil { 58 + return fmt.Errorf("subscribing to firehose failed (dialing): %w", err) 59 + } 60 + 61 + rsc := &events.RepoStreamCallbacks{ 62 + RepoCommit: bf.handleCommitEvent, 63 + RepoSync: bf.handleSyncEvent, 64 + RepoIdentity: bf.handleIdentityEvent, 65 + RepoAccount: bf.handleAccountEvent, 66 + } 67 + 68 + scheduler := parallel.NewScheduler( 69 + 1, 70 + 100, 71 + relayHost, 72 + rsc.EventHandler, 73 + ) 74 + slog.Info("starting firehose consumer", "relayHost", relayHost) 75 + return events.HandleRepoStream(ctx, con, scheduler, nil) 76 + } 77 + 78 + func (bf *Backfiller) handleIdentityEvent(evt *comatproto.SyncSubscribeRepos_Identity) error { 79 + ctx := context.Background() 80 + did, err := syntax.ParseDID(evt.Did) 81 + if err != nil { 82 + slog.Warn("invalid DID", "eventType", "identity", "did", evt.Did, "seq", evt.Seq) 83 + return err 84 + } 85 + // XXX: do something more with event 86 + bf.Dir.Purge(ctx, did.AtIdentifier()) 87 + return nil 88 + } 89 + 90 + func (bf *Backfiller) handleAccountEvent(evt *comatproto.SyncSubscribeRepos_Account) error { 91 + if _, err := syntax.ParseDID(evt.Did); err != nil { 92 + slog.Warn("invalid DID in firehose message", "eventType", "account", "did", evt.Did, "seq", evt.Seq) 93 + return nil 94 + } 95 + // XXX: do something with event 96 + return nil 97 + } 98 + 99 + func (bf *Backfiller) handleSyncEvent(evt *comatproto.SyncSubscribeRepos_Sync) error { 100 + ctx := context.Background() 101 + if _, err := syntax.ParseDID(evt.Did); err != nil { 102 + slog.Warn("invalid DID", "eventType", "account", "did", evt.Did, "seq", evt.Seq) 103 + return nil 104 + } 105 + commit, _, err := repo.LoadCommitFromCAR(ctx, bytes.NewReader(evt.Blocks)) 106 + if err != nil { 107 + return err 108 + } 109 + if err := commit.VerifyStructure(); err != nil { 110 + slog.Warn("bad commit object", "eventType", "sync", "did", evt.Did, "seq", evt.Seq, "err", err) 111 + } 112 + // XXX: process #sync event 113 + return nil 114 + } 115 + 116 + func (bf *Backfiller) handleCommitEvent(evt *comatproto.SyncSubscribeRepos_Commit) error { 117 + ctx := context.Background() 118 + 119 + logger := slog.With("eventType", "commit", "did", evt.Repo, "seq", evt.Seq, "rev", evt.Rev) 120 + 121 + did, err := syntax.ParseDID(evt.Repo) 122 + if err != nil { 123 + return err 124 + } 125 + 126 + commit, _, err := repo.LoadCommitFromCAR(ctx, bytes.NewReader(evt.Blocks)) 127 + if err != nil { 128 + return err 129 + } 130 + 131 + ident, err := bf.Dir.LookupDID(ctx, did) 132 + if err != nil { 133 + return err 134 + } 135 + pubkey, err := ident.PublicKey() 136 + if err != nil { 137 + return err 138 + } 139 + logger = logger.With("pds", ident.PDSEndpoint()) 140 + if err := commit.VerifySignature(pubkey); err != nil { 141 + logger.Warn("commit signature validation failed", "err", err) 142 + // XXX: return error? 143 + return nil 144 + } 145 + 146 + if len(evt.Blocks) == 0 { 147 + logger.Warn("commit message missing blocks") 148 + // XXX: return error? 149 + return nil 150 + } 151 + 152 + // the commit itself 153 + if err := commit.VerifyStructure(); err != nil { 154 + logger.Warn("bad commit object", "err", err) 155 + } 156 + // the event fields 157 + rev, err := syntax.ParseTID(evt.Rev) 158 + if err != nil { 159 + logger.Warn("bad TID syntax in commit rev", "err", err) 160 + } 161 + if rev.String() != commit.Rev { 162 + logger.Warn("event rev != commit rev", "commitRev", commit.Rev) 163 + } 164 + if did.String() != commit.DID { 165 + logger.Warn("event DID != commit DID", "commitDID", commit.DID) 166 + } 167 + _, err = syntax.ParseDatetime(evt.Time) 168 + if err != nil { 169 + logger.Warn("bad datetime syntax in commit time", "time", evt.Time, "err", err) 170 + } 171 + if evt.TooBig { 172 + logger.Warn("deprecated tooBig commit flag set") 173 + } 174 + if evt.Rebase { 175 + logger.Warn("deprecated rebase commit flag set") 176 + } 177 + 178 + if evt.PrevData == nil { 179 + logger.Warn("prevData is nil, skipping MST check") 180 + } else { 181 + // TODO: break out this function in to smaller chunks 182 + if _, err := repo.VerifyCommitMessage(ctx, evt); err != nil { 183 + logger.Warn("failed to invert commit MST", "err", err) 184 + } 185 + } 186 + 187 + // XXX: collection filter 188 + if false { 189 + keep := false 190 + for _, op := range evt.Ops { 191 + parts := strings.SplitN(op.Path, "/", 3) 192 + if len(parts) != 2 { 193 + slog.Error("invalid record path", "path", op.Path) 194 + return nil 195 + } 196 + collection := parts[0] 197 + for _, c := range bf.CollectionFilter { 198 + if c == collection { 199 + keep = true 200 + break 201 + } 202 + } 203 + if keep == true { 204 + break 205 + } 206 + } 207 + if !keep { 208 + // TODO: log debug? 209 + return nil 210 + } 211 + } 212 + return nil 213 + } 214 + 215 + /* XXX: commitToOps 216 + _, rr, err := repo.LoadRepoFromCAR(ctx, bytes.NewReader(evt.Blocks)) 217 + if err != nil { 218 + logger.Error("failed to read repo from car", "err", err) 219 + return nil 220 + } 221 + 222 + for _, op := range evt.Ops { 223 + collection, rkey, err := syntax.ParseRepoPath(op.Path) 224 + if err != nil { 225 + logger.Error("invalid path in repo op", "eventKind", op.Action, "path", op.Path) 226 + return nil 227 + } 228 + logger = logger.With("eventKind", op.Action, "collection", collection, "rkey", rkey) 229 + 230 + if len(bf.CollectionFilter) > 0 { 231 + keep := false 232 + for _, c := range bf.CollectionFilter { 233 + if collection.String() == c { 234 + keep = true 235 + break 236 + } 237 + } 238 + if keep == false { 239 + continue 240 + } 241 + } 242 + switch op.Action { 243 + case "create", "update": 244 + coll, rkey, err := syntax.ParseRepoPath(op.Path) 245 + if err != nil { 246 + return err 247 + } 248 + // read the record bytes from blocks, and verify CID 249 + recBytes, rc, err := rr.GetRecordBytes(ctx, coll, rkey) 250 + if err != nil { 251 + logger.Error("reading record from event blocks (CAR)", "err", err) 252 + break 253 + } 254 + if op.Cid == nil || lexutil.LexLink(*rc) != *op.Cid { 255 + logger.Error("mismatch between commit op CID and record block", "recordCID", rc, "opCID", op.Cid) 256 + break 257 + } 258 + 259 + out["action"] = op.Action 260 + d, err := data.UnmarshalCBOR(recBytes) 261 + if err != nil { 262 + slog.Warn("failed to parse record CBOR") 263 + continue 264 + } 265 + out["cid"] = op.Cid.String() 266 + out["record"] = d 267 + b, err := json.Marshal(out) 268 + if err != nil { 269 + return err 270 + } 271 + case "delete": 272 + out["action"] = "delete" 273 + b, err := json.Marshal(out) 274 + if err != nil { 275 + return err 276 + } 277 + default: 278 + logger.Error("unexpected record op kind") 279 + } 280 + } 281 + return nil 282 + } 283 + */
+49
backfill/models.go
···
··· 1 + package backfill 2 + 3 + type AccountStatus string 4 + 5 + var ( 6 + // AccountStatusActive is not in the spec but used internally 7 + AccountStatusActive = AccountStatus("active") 8 + 9 + AccountStatusDeactivated = AccountStatus("deactivated") 10 + AccountStatusDeleted = AccountStatus("deleted") 11 + AccountStatusSuspended = AccountStatus("suspended") 12 + AccountStatusTakendown = AccountStatus("takendown") 13 + AccountStatusDesynchronized = AccountStatus("desynchronized") 14 + AccountStatusThrottled = AccountStatus("throttled") 15 + 16 + // generic "not active, but not known" status 17 + AccountStatusInactive = AccountStatus("inactive") 18 + ) 19 + 20 + type Account struct { 21 + UID uint64 `gorm:"column:uid;primarykey" json:"uid"` 22 + DID string `gorm:"column:did;uniqueIndex;not null" json:"did"` 23 + 24 + // this is a reference to the ID field on Host; but it is not an explicit foreign key 25 + HostID uint64 `gorm:"column:host_id;not null" json:"hostID"` 26 + Status AccountStatus `gorm:"column:status;not null;default:active" json:"status"` 27 + UpstreamStatus AccountStatus `gorm:"column:upstream_status;not null;default:active" json:"upstreamStatus"` 28 + } 29 + 30 + func (Account) TableName() string { 31 + return "account" 32 + } 33 + 34 + // This is a small extension table to `Account`, which holds fast-changing fields updated on every firehose event. 35 + type AccountRepo struct { 36 + // references Account.UID, but not set up as a foreign key 37 + UID uint64 `gorm:"column:uid;primarykey" json:"uid"` 38 + Rev string `gorm:"column:rev;not null" json:"rev"` 39 + 40 + // The CID of the entire signed commit block. Sometimes called the "head" 41 + CommitCID string `gorm:"column:commit_cid;not null" json:"commitCID"` 42 + 43 + // The CID of the top of the repo MST, which is the 'data' field within the commit block. This becomes 'prevData' 44 + CommitDataCID string `gorm:"column:commit_data_cid;not null" json:"commitDataCID"` 45 + } 46 + 47 + func (AccountRepo) TableName() string { 48 + return "account_repo" 49 + }
+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 + ]
+3 -2
go.mod
··· 7 github.com/bluesky-social/jetstream v0.0.0-20250414024304-d17bd81a945e 8 github.com/carlmjohnson/versioninfo v0.22.5 9 github.com/flosch/pongo2/v6 v6.0.0 10 github.com/hashicorp/golang-lru/v2 v2.0.7 11 github.com/ipfs/go-cid v0.4.1 12 github.com/joho/godotenv v1.5.1 13 github.com/labstack/echo/v4 v4.11.3 14 github.com/miekg/dns v1.1.66 ··· 22 ) 23 24 require ( 25 github.com/beorn7/perks v1.0.1 // indirect 26 github.com/cespare/xxhash/v2 v2.3.0 // indirect 27 github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect ··· 33 github.com/gogo/protobuf v1.3.2 // indirect 34 github.com/golang-jwt/jwt v3.2.2+incompatible // indirect 35 github.com/google/uuid v1.6.0 // indirect 36 - github.com/gorilla/websocket v1.5.1 // indirect 37 github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 38 github.com/hashicorp/go-retryablehttp v0.7.5 // indirect 39 github.com/hashicorp/golang-lru v1.0.2 // indirect ··· 45 github.com/ipfs/go-ipfs-ds-help v1.1.1 // indirect 46 github.com/ipfs/go-ipfs-exchange-interface v0.2.1 // indirect 47 github.com/ipfs/go-ipfs-util v0.0.3 // indirect 48 - github.com/ipfs/go-ipld-cbor v0.1.0 // indirect 49 github.com/ipfs/go-ipld-format v0.6.0 // indirect 50 github.com/ipfs/go-ipld-legacy v0.2.1 // indirect 51 github.com/ipfs/go-log v1.0.5 // indirect
··· 7 github.com/bluesky-social/jetstream v0.0.0-20250414024304-d17bd81a945e 8 github.com/carlmjohnson/versioninfo v0.22.5 9 github.com/flosch/pongo2/v6 v6.0.0 10 + github.com/gorilla/websocket v1.5.1 11 github.com/hashicorp/golang-lru/v2 v2.0.7 12 github.com/ipfs/go-cid v0.4.1 13 + github.com/ipfs/go-ipld-cbor v0.1.0 14 github.com/joho/godotenv v1.5.1 15 github.com/labstack/echo/v4 v4.11.3 16 github.com/miekg/dns v1.1.66 ··· 24 ) 25 26 require ( 27 + github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b // indirect 28 github.com/beorn7/perks v1.0.1 // indirect 29 github.com/cespare/xxhash/v2 v2.3.0 // indirect 30 github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect ··· 36 github.com/gogo/protobuf v1.3.2 // indirect 37 github.com/golang-jwt/jwt v3.2.2+incompatible // indirect 38 github.com/google/uuid v1.6.0 // indirect 39 github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 40 github.com/hashicorp/go-retryablehttp v0.7.5 // indirect 41 github.com/hashicorp/golang-lru v1.0.2 // indirect ··· 47 github.com/ipfs/go-ipfs-ds-help v1.1.1 // indirect 48 github.com/ipfs/go-ipfs-exchange-interface v0.2.1 // indirect 49 github.com/ipfs/go-ipfs-util v0.0.3 // indirect 50 github.com/ipfs/go-ipld-format v0.6.0 // indirect 51 github.com/ipfs/go-ipld-legacy v0.2.1 // indirect 52 github.com/ipfs/go-log v1.0.5 // indirect
+4
go.sum
··· 1 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 2 github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= 3 github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A= 4 github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= ··· 33 github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 34 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 35 github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 36 github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= 37 github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= 38 github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
··· 1 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 2 + github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b h1:5/++qT1/z812ZqBvqQt6ToRswSuPZ/B33m6xVHRzADU= 3 + github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b/go.mod h1:4+EPqMRApwwE/6yo6CxiHoSnBzjRr3jsqer7frxP8y4= 4 github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= 5 github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A= 6 github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= ··· 35 github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 36 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 37 github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 38 + github.com/go-redis/redis v6.15.9+incompatible h1:K0pv1D7EQUjfyoMql+r/jZqCLizCGKFlFgcHWWmHQjg= 39 + github.com/go-redis/redis v6.15.9+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA= 40 github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= 41 github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= 42 github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=