+212
atproto/auth/permission.go
+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
+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
+13
atproto/auth/testdata/permission_scopes_invalid.txt
+3
atproto/auth/testdata/permission_scopes_other.txt
+3
atproto/auth/testdata/permission_scopes_other.txt
+14
atproto/auth/testdata/permission_scopes_valid.txt
+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
+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
+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
+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
+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
-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
-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
-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
+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
+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
+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
+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
+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
+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
didplc/main.go
···
···
1
+
package didplc
+129
didplc/manual_test.go
+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
+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
+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
+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
+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
+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
+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
+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=