this repo has no description
0
fork

Configure Feed

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

identity: progress

+833 -42
+186
atproto/identity/cache_catalog.go
··· 1 + package identity 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "sync" 7 + "time" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + ) 11 + 12 + // TODO: refactor this to wrap a regular Catalog. have it always update both handle and identity maps together. 13 + type CacheCatalog struct { 14 + HitTTL time.Duration 15 + ErrTTL time.Duration 16 + PLCURL string 17 + mutex sync.RWMutex 18 + handleCache map[syntax.Handle]HandleEntry 19 + identityCache map[syntax.DID]IdentityEntry 20 + } 21 + 22 + type HandleEntry struct { 23 + Updated time.Time 24 + DID syntax.DID 25 + Err error 26 + } 27 + 28 + type IdentityEntry struct { 29 + Updated time.Time 30 + Identity *Identity 31 + Err error 32 + } 33 + 34 + var _ Catalog = (*CacheCatalog)(nil) 35 + 36 + func NewCacheCatalog(plcURL string) CacheCatalog { 37 + // TODO: these are kind of arbitrary default values 38 + hitTTL, err := time.ParseDuration("1h") 39 + if err != nil { 40 + panic(err) 41 + } 42 + errTTL, err := time.ParseDuration("2m") 43 + if err != nil { 44 + panic(err) 45 + } 46 + return CacheCatalog{ 47 + HitTTL: hitTTL, 48 + ErrTTL: errTTL, 49 + PLCURL: plcURL, 50 + handleCache: make(map[syntax.Handle]HandleEntry, 10), 51 + identityCache: make(map[syntax.DID]IdentityEntry, 10), 52 + } 53 + } 54 + 55 + func (c *CacheCatalog) updateHandle(ctx context.Context, h syntax.Handle) (*HandleEntry, error) { 56 + did, err := ResolveHandle(ctx, h) 57 + entry := HandleEntry{ 58 + Updated: time.Now(), 59 + DID: did, 60 + Err: err, 61 + } 62 + c.mutex.Lock() 63 + c.handleCache[h] = entry 64 + c.mutex.Unlock() 65 + return &entry, nil 66 + } 67 + 68 + func (c *CacheCatalog) ResolveHandle(ctx context.Context, h syntax.Handle) (syntax.DID, error) { 69 + var err error 70 + var entry *HandleEntry 71 + c.mutex.RLock() 72 + eObj, ok := c.handleCache[h] 73 + c.mutex.RUnlock() 74 + 75 + if !ok { 76 + entry, err = c.updateHandle(ctx, h) 77 + if err != nil { 78 + return "", err 79 + } 80 + } else { 81 + entry = &eObj 82 + } 83 + if (entry.Err == nil && time.Since(entry.Updated) > c.HitTTL) || (entry.Err != nil && time.Since(entry.Updated) > c.ErrTTL) { 84 + entry, err = c.updateHandle(ctx, h) 85 + if err != nil { 86 + return "", err 87 + } 88 + } 89 + return entry.DID, entry.Err 90 + } 91 + 92 + func (c *CacheCatalog) getIdentity(ctx context.Context, did syntax.DID) (*Identity, error) { 93 + doc, err := ResolveDID(ctx, did) 94 + if err != nil { 95 + return nil, err 96 + } 97 + ident := ParseIdentity(doc) 98 + declared, err := ident.DeclaredHandle() 99 + if err != nil { 100 + return nil, err 101 + } 102 + resolvedDID, err := c.ResolveHandle(ctx, declared) 103 + if err != nil { 104 + return nil, err 105 + } 106 + if resolvedDID == did { 107 + ident.Handle = declared 108 + } 109 + 110 + // optimistic caching of public key 111 + pk, err := ident.PublicKey() 112 + if nil == err { 113 + ident.ParsedPublicKey = pk 114 + } 115 + return &ident, nil 116 + } 117 + 118 + func (c *CacheCatalog) updateIdentity(ctx context.Context, did syntax.DID) (*IdentityEntry, error) { 119 + ident, err := c.getIdentity(ctx, did) 120 + entry := IdentityEntry{ 121 + Updated: time.Now(), 122 + Identity: ident, 123 + Err: err, 124 + } 125 + 126 + c.mutex.Lock() 127 + c.identityCache[did] = entry 128 + c.mutex.Unlock() 129 + return &entry, nil 130 + } 131 + 132 + func (c *CacheCatalog) LookupDID(ctx context.Context, did syntax.DID) (*Identity, error) { 133 + var err error 134 + var entry *IdentityEntry 135 + c.mutex.RLock() 136 + eObj, ok := c.identityCache[did] 137 + c.mutex.RUnlock() 138 + 139 + if !ok { 140 + entry, err = c.updateIdentity(ctx, did) 141 + if err != nil { 142 + return nil, err 143 + } 144 + } else { 145 + entry = &eObj 146 + } 147 + if (entry.Err == nil && time.Since(entry.Updated) > c.HitTTL) || (entry.Err != nil && time.Since(entry.Updated) > c.ErrTTL) { 148 + entry, err = c.updateIdentity(ctx, did) 149 + if err != nil { 150 + return nil, err 151 + } 152 + } 153 + return entry.Identity, entry.Err 154 + } 155 + 156 + func (c *CacheCatalog) LookupHandle(ctx context.Context, h syntax.Handle) (*Identity, error) { 157 + did, err := c.ResolveHandle(ctx, h) 158 + if err != nil { 159 + return nil, err 160 + } 161 + ident, err := c.LookupDID(ctx, did) 162 + if err != nil { 163 + return nil, err 164 + } 165 + 166 + declared, err := ident.DeclaredHandle() 167 + if err != nil { 168 + return nil, err 169 + } 170 + if declared != h { 171 + return nil, fmt.Errorf("handle does not match that declared in DID document") 172 + } 173 + return ident, nil 174 + } 175 + 176 + func (c *CacheCatalog) Lookup(ctx context.Context, a syntax.AtIdentifier) (*Identity, error) { 177 + handle, err := a.AsHandle() 178 + if err == nil { 179 + return c.LookupHandle(ctx, handle) 180 + } 181 + did, err := a.AsDID() 182 + if err == nil { 183 + return c.LookupDID(ctx, did) 184 + } 185 + return nil, fmt.Errorf("at-identifier neither a Handle nor a DID") 186 + }
+50 -7
atproto/identity/cmd/atp-id/main.go
··· 1 1 package main 2 2 3 3 import ( 4 + "context" 5 + "encoding/json" 4 6 "fmt" 5 7 "os" 6 8 7 - //"github.com/bluesky-social/indigo/atproto/identity" 9 + "github.com/bluesky-social/indigo/atproto/identity" 8 10 "github.com/bluesky-social/indigo/atproto/syntax" 9 11 10 12 "github.com/urfave/cli/v2" ··· 17 19 } 18 20 app.Commands = []*cli.Command{ 19 21 &cli.Command{ 22 + Name: "lookup", 23 + Usage: "fully resolve an at-identifier (DID or handle)", 24 + Action: runLookup, 25 + }, 26 + &cli.Command{ 20 27 Name: "resolve-handle", 21 - Usage: "resolve a handle", 28 + Usage: "resolve a handle to DID", 22 29 Action: runResolveHandle, 23 30 }, 24 31 &cli.Command{ 25 32 Name: "resolve-did", 26 - Usage: "resolve a DID", 33 + Usage: "resolve a DID to DID Document", 27 34 Action: runResolveDID, 28 35 }, 29 36 } 30 37 app.RunAndExitOnError() 31 38 } 32 39 40 + func runLookup(cctx *cli.Context) error { 41 + s := cctx.Args().First() 42 + if s == "" { 43 + return fmt.Errorf("need to provide identifier as an argument") 44 + } 45 + 46 + id, err := syntax.ParseAtIdentifier(s) 47 + if err != nil { 48 + return err 49 + } 50 + fmt.Printf("valid at-identifier syntax: %s\n", id) 51 + 52 + ncat := identity.NewNaiveCatalog("https://plc.directory") 53 + 54 + acc, err := ncat.Lookup(context.TODO(), *id) 55 + if err != nil { 56 + return err 57 + } 58 + fmt.Println(acc) 59 + return nil 60 + } 61 + 33 62 func runResolveHandle(cctx *cli.Context) error { 34 63 s := cctx.Args().First() 35 64 if s == "" { 36 - fmt.Println("need to provide handle as an argument") 37 - os.Exit(-1) 65 + return fmt.Errorf("need to provide handle as an argument") 38 66 } 39 67 40 68 handle, err := syntax.ParseHandle(s) 41 69 if err != nil { 42 - fmt.Println(err) 43 - os.Exit(-1) 70 + return err 44 71 } 45 72 fmt.Printf("valid handle syntax: %s\n", handle) 73 + 74 + did, err := identity.ResolveHandle(context.TODO(), handle) 75 + if err != nil { 76 + return err 77 + } 78 + fmt.Println(did) 46 79 return nil 47 80 } 48 81 ··· 59 92 os.Exit(-1) 60 93 } 61 94 fmt.Printf("valid DID syntax: %s\n", did) 95 + 96 + doc, err := identity.ResolveDID(context.TODO(), did) 97 + if err != nil { 98 + return err 99 + } 100 + jsonBytes, err := json.Marshal(&doc) 101 + if err != nil { 102 + return err 103 + } 104 + fmt.Println(string(jsonBytes)) 62 105 return nil 63 106 }
+67 -15
atproto/identity/did.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "encoding/json" 5 6 "errors" 6 7 "fmt" 8 + "net" 9 + "net/http" 7 10 8 11 "github.com/bluesky-social/indigo/atproto/syntax" 9 12 ) ··· 11 14 type DIDDocument struct { 12 15 DID syntax.DID `json:"id"` 13 16 AlsoKnownAs []string `json:"alsoKnownAs,omitempty"` 14 - VerificationMethod []DocVerificationMethod `json:"alsoKnownAs,omitempty"` 15 - Service []DocService `json:"alsoKnownAs,omitempty"` 17 + VerificationMethod []DocVerificationMethod `json:"verificationMethod,omitempty"` 18 + Service []DocService `json:"service,omitempty"` 16 19 } 17 20 18 21 type DocVerificationMethod struct { ··· 28 31 ServiceEndpoint string `json:"serviceEndpoint"` 29 32 } 30 33 34 + // Indicates that resolution process completed successfully, but the DID does not exist. 31 35 var ErrDIDNotFound = errors.New("DID not found") 32 36 33 - // WARNING: this does *not* bi-directionally verify account metadata; it only implements direct DID-to-DID-document lookup for the supported DID methods, and parses the resulting DID Doc into an Account struct 37 + // WARNING: this does *not* bi-directionally verify account metadata; it only implements direct DID-to-DID-document lookup for the supported DID methods, and parses the resulting DID Doc into an Identity struct 34 38 func ResolveDID(ctx context.Context, did syntax.DID) (*DIDDocument, error) { 35 39 switch did.Method() { 36 40 case "web": 37 - panic("NOT IMPLEMENTED") 41 + return ResolveDIDWeb(ctx, did) 38 42 case "plc": 39 - panic("NOT IMPLEMENTED") 43 + return ResolveDIDPLC(ctx, did) 40 44 default: 41 45 return nil, fmt.Errorf("DID method not supported: %s", did.Method()) 42 46 } 43 47 } 44 48 45 49 func ResolveDIDWeb(ctx context.Context, did syntax.DID) (*DIDDocument, error) { 46 - return nil, fmt.Errorf("XXX UNIMPLEMENTED") 47 - } 50 + if did.Method() != "web" { 51 + return nil, fmt.Errorf("expected a did:web, got: %s", did) 52 + } 53 + hostname := did.Identifier() 54 + handle, err := syntax.ParseHandle(hostname) 55 + if err != nil { 56 + return nil, fmt.Errorf("did:web identifier not a simple hostname: %s", hostname) 57 + } 58 + if !handle.AllowedTLD() { 59 + return nil, fmt.Errorf("did:web hostname has disallowed TLD: %s", hostname) 60 + } 48 61 49 - func ResolvePLC(ctx context.Context, did syntax.DID) (*DIDDocument, error) { 50 - return nil, fmt.Errorf("XXX UNIMPLEMENTED") 51 - } 62 + // TODO: use a more robust client 63 + // TODO: allow ctx to specify unsafe http:// resolution, for testing? 64 + resp, err := http.Get("https://" + hostname + "/.well-known/did.json") 65 + // look for NXDOMAIN 66 + var dnsErr *net.DNSError 67 + if errors.As(err, &dnsErr) { 68 + if dnsErr.IsNotFound { 69 + return nil, ErrDIDNotFound 70 + } 71 + } 72 + if err != nil { 73 + return nil, fmt.Errorf("failed HTTP fetch of did:web well-known document: %w", err) 74 + } 75 + if resp.StatusCode == 404 { 76 + return nil, ErrDIDNotFound 77 + } 78 + // TODO: HTTP redirects 79 + if resp.StatusCode != 200 { 80 + return nil, fmt.Errorf("failed did:web well-known fetch, HTTP status: %d", resp.StatusCode) 81 + } 52 82 53 - func (d *DIDDocument) Account() Account { 54 - panic("XXX UNIMPLEMENTED") 83 + var doc DIDDocument 84 + if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { 85 + return nil, fmt.Errorf("failed parse of did:web document JSON: %w", err) 86 + } 87 + return &doc, nil 55 88 } 56 89 57 - // "Renders" a DID Document 58 - func (a *Account) DIDDocument() DIDDocument { 59 - panic("XXX UNIMPLEMENTED") 90 + func ResolveDIDPLC(ctx context.Context, did syntax.DID) (*DIDDocument, error) { 91 + // TODO: configurable PLC hostname 92 + if did.Method() != "plc" { 93 + return nil, fmt.Errorf("expected a did:plc, got: %s", did) 94 + } 95 + 96 + resp, err := http.Get("https://plc.directory/" + did.String()) 97 + if err != nil { 98 + return nil, fmt.Errorf("failed did:plc directory resolution: %w", err) 99 + } 100 + if resp.StatusCode == 404 { 101 + return nil, ErrDIDNotFound 102 + } 103 + if resp.StatusCode != 200 { 104 + return nil, fmt.Errorf("failed did:web well-known fetch, HTTP status: %d", resp.StatusCode) 105 + } 106 + 107 + var doc DIDDocument 108 + if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { 109 + return nil, fmt.Errorf("failed parse of did:plc document JSON: %w", err) 110 + } 111 + return &doc, nil 60 112 }
+46
atproto/identity/did_test.go
··· 1 + package identity 2 + 3 + import ( 4 + "encoding/json" 5 + "io" 6 + "os" 7 + "testing" 8 + 9 + "github.com/stretchr/testify/assert" 10 + ) 11 + 12 + func TestDIDDocParse(t *testing.T) { 13 + assert := assert.New(t) 14 + docFiles := []string{ 15 + "testdata/did_plc_doc.json", 16 + "testdata/did_plc_doc_legacy.json", 17 + } 18 + for _, path := range docFiles { 19 + f, err := os.Open(path) 20 + if err != nil { 21 + t.Fatal(err) 22 + } 23 + defer f.Close() 24 + 25 + docBytes, err := io.ReadAll(f) 26 + if err != nil { 27 + t.Fatal(err) 28 + } 29 + 30 + var doc DIDDocument 31 + err = json.Unmarshal(docBytes, &doc) 32 + assert.NoError(err) 33 + 34 + id := ParseIdentity(&doc) 35 + 36 + assert.Equal("did:plc:ewvi7nxzyoun6zhxrhs64oiz", id.DID.String()) 37 + assert.Equal([]string{"at://atproto.com"}, id.AlsoKnownAs) 38 + pk, err := id.PublicKey() 39 + assert.NoError(err) 40 + assert.NotNil(pk) 41 + assert.Equal("https://bsky.social", id.PDSEndpoint()) 42 + hdl, err := id.DeclaredHandle() 43 + assert.NoError(err) 44 + assert.Equal("atproto.com", hdl.String()) 45 + } 46 + }
+39 -1
atproto/identity/handle.go
··· 12 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 13 ) 14 14 15 + // Indicates that resolution process completed successfully, but handle does not exist. 15 16 var ErrHandleNotFound = errors.New("handle not found") 16 17 17 18 // Does not cross-verify, just does the handle resolution step. 18 19 func ResolveHandleDNS(ctx context.Context, handle syntax.Handle) (syntax.DID, error) { 20 + // TODO: timeout 21 + // TODO: mechanism to control resolution; context? separate method? 19 22 20 23 res, err := net.LookupTXT("_atproto." + handle.String()) 24 + // look for NXDOMAIN 25 + var dnsErr *net.DNSError 26 + if errors.As(err, &dnsErr) { 27 + if dnsErr.IsNotFound { 28 + return "", ErrHandleNotFound 29 + } 30 + } 21 31 if err != nil { 22 32 return "", fmt.Errorf("handle DNS resolution failed: %w", err) 23 33 } ··· 36 46 } 37 47 38 48 func ResolveHandleWellKnown(ctx context.Context, handle syntax.Handle) (syntax.DID, error) { 39 - // NOTE: could pull a client or transport from context 49 + // TODO: could pull a client or transport from context? 50 + // TODO: timeout 40 51 c := http.DefaultClient 41 52 42 53 req, err := http.NewRequest("GET", fmt.Sprintf("https://%s/.well-known/atproto-did", handle), nil) ··· 47 58 48 59 resp, err := c.Do(req) 49 60 if err != nil { 61 + // look for NXDOMAIN 62 + var dnsErr *net.DNSError 63 + if errors.As(err, &dnsErr) { 64 + if dnsErr.IsNotFound { 65 + return "", ErrHandleNotFound 66 + } 67 + } 50 68 return "", fmt.Errorf("failed to resolve handle (%s) through HTTP well-known route: %s", handle, err) 51 69 } 52 70 if resp.StatusCode != 200 { ··· 64 82 line := strings.TrimSpace(string(b)) 65 83 return syntax.ParseDID(line) 66 84 } 85 + 86 + func ResolveHandle(ctx context.Context, handle syntax.Handle) (syntax.DID, error) { 87 + did, dnsErr := ResolveHandleDNS(ctx, handle) 88 + if dnsErr == nil { 89 + return did, nil 90 + } 91 + did, httpErr := ResolveHandleWellKnown(ctx, handle) 92 + if httpErr == nil { 93 + return did, nil 94 + } 95 + 96 + // return the most specific/helpful error 97 + if dnsErr != ErrHandleNotFound { 98 + return "", dnsErr 99 + } 100 + if httpErr != ErrHandleNotFound { 101 + return "", httpErr 102 + } 103 + return "", dnsErr 104 + }
+126 -19
atproto/identity/identity.go
··· 1 1 package identity 2 2 3 3 import ( 4 + "context" 5 + "fmt" 6 + "strings" 7 + 8 + "github.com/bluesky-social/indigo/atproto/crypto" 4 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 + 11 + "github.com/mr-tron/base58" 5 12 ) 6 13 7 14 // API for doing account lookups by DID or handle, with bi-directional verification handled automatically. 8 15 // 9 - // Handles which fail to resolve or don't match DID alsoKnownAs are an error. DIDs which resolve but the handle does not resolve back to the DID return an Account where the Handle is the special `handle.invalid` value. 16 + // Handles which fail to resolve or don't match DID alsoKnownAs are an error. DIDs which resolve but the handle does not resolve back to the DID return an Identity where the Handle is the special `handle.invalid` value. 10 17 // 11 18 // Some example implementations of this interface would be: 12 19 // - naive direct resolution on every call 13 20 // - API client, which just makes requests to PDS (or other remote service) 14 21 // - simple in-memory caching wrapper layer to reduce network hits 15 22 // - services with backing datastore to do sophisticated caching, TTL, auto-refresh, etc 16 - type AccountCatalog interface { 17 - LookupHandle(h syntax.Handle) (*Account, error) 18 - LookupDID(d syntax.DID) (*Account, error) 23 + type Catalog interface { 24 + LookupHandle(ctx context.Context, h syntax.Handle) (*Identity, error) 25 + LookupDID(ctx context.Context, d syntax.DID) (*Identity, error) 26 + Lookup(ctx context.Context, i syntax.AtIdentifier) (*Identity, error) 27 + // TODO: add "flush" methods to purge caches? 28 + } 29 + 30 + var DefaultPLCURL = "https://plc.directory" 31 + 32 + func DefaultCatalog() Catalog { 33 + cat := NewCacheCatalog(DefaultPLCURL) 34 + return &cat 19 35 } 20 36 37 + // TODO: DIDHistory() helper, returns log of declared handle, PDS location, and public key? or maybe this is something best left to did-method-plc helper library 38 + 21 39 // Represents an atproto identity. Could be a regular user account, or a service account (eg, feed generator) 22 - type Account struct { 23 - // these fields are required and non-nullable 24 - DID syntax.DID 40 + type Identity struct { 41 + DID syntax.DID 42 + 43 + // Handle/DID mapping must be bi-directionally verified. If that fails, the Handle should be the special 'handle.invalid' value 44 + // TODO: should we make this nullable, instead of 'handle.invalid'? 25 45 Handle syntax.Handle 26 46 27 - // these fields are nullable 47 + // These fields represent a parsed subset of a DID document. They are all nullable. 48 + // TODO: should we just embed DIDDocument here? 28 49 AlsoKnownAs []string 29 - Services map[string]Service 30 - Keys map[string]Key 50 + // TODO: this doesn't preserve order (doesn't round-trip) 51 + Services map[string]Service 52 + // TODO: this doesn't preserve order (doesn't round-trip) 53 + Keys map[string]Key 54 + 55 + // If a valid atproto repo signing public key was parsed, it can be cached here. This is nullable/optional. 56 + ParsedPublicKey crypto.PublicKey 31 57 } 32 58 33 59 type Key struct { ··· 40 66 URL string 41 67 } 42 68 43 - func (a *Account) SigningKey() *Key { 44 - if a.Keys == nil { 45 - return nil 69 + func ParseIdentity(doc *DIDDocument) Identity { 70 + keys := make(map[string]Key, len(doc.VerificationMethod)) 71 + for _, vm := range doc.VerificationMethod { 72 + parts := strings.SplitN(vm.ID, "#", 2) 73 + if len(parts) < 2 { 74 + continue 75 + } 76 + // ignore keys not controlled by this DID itself 77 + if vm.Controller != doc.DID.String() { 78 + continue 79 + } 80 + // don't want to clobber existing entries with same ID fragment 81 + if _, ok := keys[parts[1]]; ok { 82 + continue 83 + } 84 + // TODO: verify that ID and type match for atproto-specific services? 85 + keys[parts[1]] = Key{ 86 + Type: vm.Type, 87 + PublicKeyMultibase: vm.PublicKeyMultibase, 88 + } 89 + } 90 + svc := make(map[string]Service, len(doc.Service)) 91 + for _, s := range doc.Service { 92 + parts := strings.SplitN(s.ID, "#", 2) 93 + if len(parts) < 2 { 94 + continue 95 + } 96 + // don't want to clobber existing entries with same ID fragment 97 + if _, ok := svc[parts[1]]; ok { 98 + continue 99 + } 100 + // TODO: verify that ID and type match for atproto-specific services? 101 + svc[parts[1]] = Service{ 102 + Type: s.Type, 103 + URL: s.ServiceEndpoint, 104 + } 46 105 } 47 - atp, ok := a.Keys["atproto"] 106 + return Identity{ 107 + DID: doc.DID, 108 + Handle: syntax.Handle("invalid.handle"), 109 + AlsoKnownAs: doc.AlsoKnownAs, 110 + Services: svc, 111 + Keys: keys, 112 + } 113 + } 114 + 115 + func (i *Identity) PublicKey() (crypto.PublicKey, error) { 116 + if i.ParsedPublicKey != nil { 117 + return i.ParsedPublicKey, nil 118 + } 119 + if i.Keys == nil { 120 + return nil, fmt.Errorf("identity has no atproto public key attached") 121 + } 122 + k, ok := i.Keys["atproto"] 48 123 if !ok { 49 - return nil 124 + return nil, fmt.Errorf("identity has no atproto public key attached") 50 125 } 51 - return &atp 126 + switch k.Type { 127 + case "Multikey": 128 + return crypto.ParsePublicMultibase(k.PublicKeyMultibase) 129 + case "EcdsaSecp256r1VerificationKey2019": 130 + if len(k.PublicKeyMultibase) < 2 || k.PublicKeyMultibase[0] != 'z' { 131 + return nil, fmt.Errorf("identity key not a multibase base58btc string") 132 + } 133 + keyBytes, err := base58.Decode(k.PublicKeyMultibase[1:]) 134 + if err != nil { 135 + return nil, fmt.Errorf("identity key multibase parsing: %w", err) 136 + } 137 + return crypto.ParsePublicUncompressedBytesP256(keyBytes) 138 + case "EcdsaSecp256k1VerificationKey2019": 139 + if len(k.PublicKeyMultibase) < 2 || k.PublicKeyMultibase[0] != 'z' { 140 + return nil, fmt.Errorf("identity key not a multibase base58btc string") 141 + } 142 + keyBytes, err := base58.Decode(k.PublicKeyMultibase[1:]) 143 + if err != nil { 144 + return nil, fmt.Errorf("identity key multibase parsing: %w", err) 145 + } 146 + return crypto.ParsePublicUncompressedBytesK256(keyBytes) 147 + default: 148 + return nil, fmt.Errorf("unsupported atproto public key type: %s", k.Type) 149 + } 52 150 } 53 151 54 - func (a *Account) PDS() string { 55 - if a.Services == nil { 152 + func (i *Identity) PDSEndpoint() string { 153 + if i.Services == nil { 56 154 return "" 57 155 } 58 - atp, ok := a.Services["atproto_pds"] 156 + atp, ok := i.Services["atproto_pds"] 59 157 if !ok { 60 158 return "" 61 159 } 62 160 return atp.URL 63 161 } 162 + 163 + func (i *Identity) DeclaredHandle() (syntax.Handle, error) { 164 + for _, u := range i.AlsoKnownAs { 165 + if strings.HasPrefix(u, "at://") && len(u) > len("at://") { 166 + return syntax.ParseHandle(u[5:]) 167 + } 168 + } 169 + return "", fmt.Errorf("DID document contains no atproto handle") 170 + }
+64
atproto/identity/live_test.go
··· 1 + package identity 2 + 3 + import ( 4 + "context" 5 + "testing" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + 9 + "github.com/stretchr/testify/assert" 10 + ) 11 + 12 + // NOTE: this hits the open internet! marked as skip below by default 13 + func testCatalogLive(t *testing.T, c Catalog) { 14 + assert := assert.New(t) 15 + ctx := context.TODO() 16 + 17 + handle := syntax.Handle("atproto.com") 18 + did := syntax.DID("did:plc:ewvi7nxzyoun6zhxrhs64oiz") 19 + pds := "https://bsky.social" 20 + 21 + resp, err := c.LookupHandle(ctx, handle) 22 + assert.NoError(err) 23 + assert.Equal(handle, resp.Handle) 24 + assert.Equal(did, resp.DID) 25 + assert.Equal(pds, resp.PDSEndpoint()) 26 + dh, err := resp.DeclaredHandle() 27 + assert.NoError(err) 28 + assert.Equal(handle, dh) 29 + pk, err := resp.PublicKey() 30 + assert.NoError(err) 31 + assert.NotNil(pk) 32 + 33 + resp, err = c.LookupDID(ctx, did) 34 + assert.NoError(err) 35 + assert.Equal(handle, resp.Handle) 36 + assert.Equal(did, resp.DID) 37 + assert.Equal(pds, resp.PDSEndpoint()) 38 + 39 + _, err = c.LookupHandle(ctx, syntax.Handle("fake-dummy-no-resolve.atproto.com")) 40 + assert.Equal(ErrHandleNotFound, err) 41 + 42 + _, err = c.LookupDID(ctx, syntax.DID("did:web:fake-dummy-no-resolve.atproto.com")) 43 + assert.Equal(ErrDIDNotFound, err) 44 + 45 + _, err = c.LookupDID(ctx, syntax.DID("did:plc:fake-dummy-no-resolve.atproto.com")) 46 + assert.Equal(ErrDIDNotFound, err) 47 + 48 + _, err = c.LookupHandle(ctx, syntax.Handle("handle.invalid")) 49 + assert.Error(err) 50 + } 51 + 52 + func TestNaiveCatalog(t *testing.T) { 53 + t.Skip("skipping live network test") 54 + c := NewNaiveCatalog(DefaultPLCURL) 55 + testCatalogLive(t, &c) 56 + } 57 + 58 + func TestCacheCatalog(t *testing.T) { 59 + t.Skip("skipping live network test") 60 + c := NewCacheCatalog(DefaultPLCURL) 61 + for i := 0; i < 3; i = i + 1 { 62 + testCatalogLive(t, &c) 63 + } 64 + }
+63
atproto/identity/mock_catalog.go
··· 1 + package identity 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + ) 9 + 10 + // A fake identity catalog, for use in tests 11 + // TODO: should probably move this to a 'mockcatalog' sub-package? 12 + type MockCatalog struct { 13 + Handles map[syntax.Handle]syntax.DID 14 + Identities map[syntax.DID]Identity 15 + } 16 + 17 + var _ Catalog = (*MockCatalog)(nil) 18 + 19 + func NewMockCatalog() MockCatalog { 20 + return MockCatalog{ 21 + Handles: make(map[syntax.Handle]syntax.DID), 22 + Identities: make(map[syntax.DID]Identity), 23 + } 24 + } 25 + 26 + func (c *MockCatalog) Insert(ident Identity) { 27 + if !ident.Handle.IsInvalidHandle() { 28 + c.Handles[ident.Handle] = ident.DID 29 + } 30 + c.Identities[ident.DID] = ident 31 + } 32 + 33 + func (c *MockCatalog) LookupHandle(ctx context.Context, h syntax.Handle) (*Identity, error) { 34 + did, ok := c.Handles[h] 35 + if !ok { 36 + return nil, ErrHandleNotFound 37 + } 38 + ident, ok := c.Identities[did] 39 + if !ok { 40 + return nil, ErrDIDNotFound 41 + } 42 + return &ident, nil 43 + } 44 + 45 + func (c *MockCatalog) LookupDID(ctx context.Context, did syntax.DID) (*Identity, error) { 46 + ident, ok := c.Identities[did] 47 + if !ok { 48 + return nil, ErrDIDNotFound 49 + } 50 + return &ident, nil 51 + } 52 + 53 + func (c *MockCatalog) Lookup(ctx context.Context, a syntax.AtIdentifier) (*Identity, error) { 54 + handle, err := a.AsHandle() 55 + if err == nil { 56 + return c.LookupHandle(ctx, handle) 57 + } 58 + did, err := a.AsDID() 59 + if err == nil { 60 + return c.LookupDID(ctx, did) 61 + } 62 + return nil, fmt.Errorf("at-identifier neither a Handle nor a DID") 63 + }
+55
atproto/identity/mock_catalog_test.go
··· 1 + package identity 2 + 3 + import ( 4 + "context" 5 + "testing" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + 9 + "github.com/stretchr/testify/assert" 10 + ) 11 + 12 + func TestMockCatalog(t *testing.T) { 13 + var err error 14 + assert := assert.New(t) 15 + ctx := context.TODO() 16 + c := NewMockCatalog() 17 + id1 := Identity{ 18 + DID: syntax.DID("did:plc:abc111"), 19 + Handle: syntax.Handle("handle.example.com"), 20 + } 21 + id2 := Identity{ 22 + DID: syntax.DID("did:plc:abc222"), 23 + Handle: syntax.Handle("handle.invalid"), 24 + } 25 + id3 := Identity{ 26 + DID: syntax.DID("did:plc:abc333"), 27 + Handle: syntax.Handle("handle3.example.com"), 28 + } 29 + 30 + // first, empty catalog 31 + _, err = c.LookupHandle(ctx, syntax.Handle("handle.example.com")) 32 + assert.Equal(ErrHandleNotFound, err) 33 + _, err = c.LookupDID(ctx, syntax.DID("did:plc:abc123")) 34 + assert.Equal(ErrDIDNotFound, err) 35 + 36 + c.Insert(id1) 37 + c.Insert(id2) 38 + c.Insert(id3) 39 + 40 + out, err := c.LookupHandle(ctx, syntax.Handle("handle.example.com")) 41 + assert.NoError(err) 42 + assert.Equal(&id1, out) 43 + out, err = c.LookupDID(ctx, syntax.DID("did:plc:abc111")) 44 + assert.NoError(err) 45 + assert.Equal(&id1, out) 46 + 47 + out, err = c.LookupDID(ctx, syntax.DID("did:plc:abc222")) 48 + assert.NoError(err) 49 + assert.True(out.Handle.IsInvalidHandle()) 50 + 51 + _, err = c.LookupHandle(ctx, syntax.Handle("handle.invalid")) 52 + assert.Equal(ErrHandleNotFound, err) 53 + out, err = c.LookupDID(ctx, syntax.DID("did:plc:abc999")) 54 + assert.Equal(ErrDIDNotFound, err) 55 + }
+86
atproto/identity/naive_catalog.go
··· 1 + package identity 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + ) 9 + 10 + type NaiveCatalog struct { 11 + // TODO: actually wire through use of PLC host 12 + PLCHost string 13 + } 14 + 15 + var _ Catalog = (*NaiveCatalog)(nil) 16 + 17 + func NewNaiveCatalog(plcHost string) NaiveCatalog { 18 + return NaiveCatalog{ 19 + PLCHost: plcHost, 20 + } 21 + } 22 + 23 + func (c *NaiveCatalog) LookupHandle(ctx context.Context, h syntax.Handle) (*Identity, error) { 24 + did, err := ResolveHandle(ctx, h) 25 + if err != nil { 26 + return nil, err 27 + } 28 + doc, err := ResolveDID(ctx, did) 29 + if err != nil { 30 + return nil, err 31 + } 32 + ident := ParseIdentity(doc) 33 + declared, err := ident.DeclaredHandle() 34 + if err != nil { 35 + return nil, err 36 + } 37 + if declared != h { 38 + return nil, fmt.Errorf("handle does not match that declared in DID document") 39 + } 40 + ident.Handle = declared 41 + 42 + // optimistic caching of public key 43 + pk, err := ident.PublicKey() 44 + if nil == err { 45 + ident.ParsedPublicKey = pk 46 + } 47 + return &ident, nil 48 + } 49 + 50 + func (c *NaiveCatalog) LookupDID(ctx context.Context, did syntax.DID) (*Identity, error) { 51 + doc, err := ResolveDID(ctx, did) 52 + if err != nil { 53 + return nil, err 54 + } 55 + ident := ParseIdentity(doc) 56 + declared, err := ident.DeclaredHandle() 57 + if err != nil { 58 + return nil, err 59 + } 60 + resolvedDID, err := ResolveHandle(ctx, declared) 61 + if err != nil { 62 + return nil, err 63 + } 64 + if resolvedDID == did { 65 + ident.Handle = declared 66 + } 67 + 68 + // optimistic caching of public key 69 + pk, err := ident.PublicKey() 70 + if nil == err { 71 + ident.ParsedPublicKey = pk 72 + } 73 + return &ident, nil 74 + } 75 + 76 + func (c *NaiveCatalog) Lookup(ctx context.Context, a syntax.AtIdentifier) (*Identity, error) { 77 + handle, err := a.AsHandle() 78 + if err == nil { 79 + return c.LookupHandle(ctx, handle) 80 + } 81 + did, err := a.AsDID() 82 + if err == nil { 83 + return c.LookupDID(ctx, did) 84 + } 85 + return nil, fmt.Errorf("at-identifier neither a Handle nor a DID") 86 + }
+26
atproto/identity/testdata/did_plc_doc.json
··· 1 + { 2 + "@context": [ 3 + "https://www.w3.org/ns/did/v1", 4 + "https://w3id.org/security/multikey/v1", 5 + "https://w3id.org/security/suites/secp256k1-2019/v1" 6 + ], 7 + "id": "did:plc:ewvi7nxzyoun6zhxrhs64oiz", 8 + "alsoKnownAs": [ 9 + "at://atproto.com" 10 + ], 11 + "verificationMethod": [ 12 + { 13 + "id": "did:plc:ewvi7nxzyoun6zhxrhs64oiz#atproto", 14 + "type": "Multikey", 15 + "controller": "did:plc:ewvi7nxzyoun6zhxrhs64oiz", 16 + "publicKeyMultibase": "zQ3shXjHeiBuRCKmM36cuYnm7YEMzhGnCmCyW92sRJ9pribSF" 17 + } 18 + ], 19 + "service": [ 20 + { 21 + "id": "#atproto_pds", 22 + "type": "AtprotoPersonalDataServer", 23 + "serviceEndpoint": "https://bsky.social" 24 + } 25 + ] 26 + }
+25
atproto/identity/testdata/did_plc_doc_legacy.json
··· 1 + { 2 + "@context": [ 3 + "https://www.w3.org/ns/did/v1", 4 + "https://w3id.org/security/suites/secp256k1-2019/v1" 5 + ], 6 + "id": "did:plc:ewvi7nxzyoun6zhxrhs64oiz", 7 + "alsoKnownAs": [ 8 + "at://atproto.com" 9 + ], 10 + "verificationMethod": [ 11 + { 12 + "id": "#atproto", 13 + "type": "EcdsaSecp256k1VerificationKey2019", 14 + "controller": "did:plc:ewvi7nxzyoun6zhxrhs64oiz", 15 + "publicKeyMultibase": "zQYEBzXeuTM9UR3rfvNag6L3RNAs5pQZyYPsomTsgQhsxLdEgCrPTLgFna8yqCnxPpNT7DBk6Ym3dgPKNu86vt9GR" 16 + } 17 + ], 18 + "service": [ 19 + { 20 + "id": "#atproto_pds", 21 + "type": "AtprotoPersonalDataServer", 22 + "serviceEndpoint": "https://bsky.social" 23 + } 24 + ] 25 + }