this repo has no description
0
fork

Configure Feed

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

identity: add `Resolver` interface; and `SkipHandleVerification` flag on `BaseDirectory` (#872)

*NOTE: see comments below; the PR has been updated to be about a new
`identity.Resolver` interface, instead of expanding
`identity.Directory`*

This is a step in the right direction for this package, I think:

- `Lookup*` for full bi-di verification, and gives a compact/ergonomic
struct for atproto use-cases (and good for caching)
- `Resolve*` does more bare-bones resolution: you get specifically what
you ask for. Things like Relay, or pure service auth validation, which
don't care about handles, can use `ResolveDID`

Downsides and open questions:

- `ResolveDID` doesn't return the raw DID doc verbatim, it is a struct,
and might be discarding info (like fields in DID core specification that
we aren't using). I think this is good enough, maybe `BaseDirectory` can
expose more "Raw" variants which return `json.RawMessage` or
`map[string]any` or something.
- the struct returned by `ResolveDID` isn't the same `Identity` type
used in possible function signatures. They can be converted, but it uses
some allocs etc. I think adding a helper to quickly extract the DID
signing key and/or PDS endpoint is probably sufficient? But feels a bit
redundant with `Identity` struct
- caching: do we cache one of the `Identity` struct (compact) or the
`DIDDocument` struct, and convert between? Or both, would be less
conversions but more RAM? I think what we should do is cache what is
actually requested. `Lookup*` caches `Identity`, `ResolveDID` caches
DIDDocument, and these caches aren't shared. Services will be using one
or the other anyways. What about an identity caching layer? That could
cache the raw DID document and handle resolution, and convert to
`Identity` on demand.

Alternative would be breaking changes to existing structs (which are
heavily cached), or maybe a flag or variant like
`LookupDIDWithoutHandle`?

Another alternative is to set a flag to skip handle resolution; I don't
like this: https://github.com/bluesky-social/indigo/pull/854

An upcoming change here is to add `ResolveNSID`, which will be used for
Lexicon resolution.

(have not pushed implementation of exiting interfaces until design
questions are resolved)

authored by

bnewbold and committed by
GitHub
74177efc 62787f7a

+176 -40
+9
atproto/identity/base_directory.go
··· 29 29 SkipDNSDomainSuffixes []string 30 30 // set of fallback DNS servers (eg, domain registrars) to try as a fallback. each entry should be "ip:port", eg "8.8.8.8:53" 31 31 FallbackDNSServers []string 32 + // skips bi-directional verification of handles when doing DID lookups (eg, `LookupDID`). Does not impact direct resolution (`ResolveHandle`) or handle-specific lookup (`LookupHandle`). 33 + // 34 + // The intended use-case for this flag is as an optimization for services which do not care about handles, but still want to use the `Directory` interface (instead of `ResolveDID`). For example, relay implementations, or services validating inter-service auth requests. 35 + SkipHandleVerification bool 32 36 } 33 37 34 38 var _ Directory = (*BaseDirectory)(nil) 39 + var _ Resolver = (*BaseDirectory)(nil) 35 40 36 41 func (d *BaseDirectory) LookupHandle(ctx context.Context, h syntax.Handle) (*Identity, error) { 37 42 h = h.Normalize() ··· 62 67 return nil, err 63 68 } 64 69 ident := ParseIdentity(doc) 70 + if d.SkipHandleVerification { 71 + ident.Handle = syntax.HandleInvalid 72 + return &ident, nil 73 + } 65 74 declared, err := ident.DeclaredHandle() 66 75 if errors.Is(err, ErrHandleNotDeclared) { 67 76 ident.Handle = syntax.HandleInvalid
+54 -21
atproto/identity/did.go
··· 5 5 "encoding/json" 6 6 "errors" 7 7 "fmt" 8 + "io" 8 9 "log/slog" 9 10 "net" 10 11 "net/http" ··· 13 14 "github.com/bluesky-social/indigo/atproto/syntax" 14 15 ) 15 16 16 - // 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 17 + // Resolves a DID to a parsed `DIDDocument` struct. 18 + // 19 + // This method does not bi-directionally verify handles. Most atproto-specific code should use the `identity.Directory` interface ("Lookup" methods), which implement that check by default, and provide more ergonomic helpers for working with atproto-relevant information in DID documents. 20 + // 21 + // Note that the `DIDDocument` might not include all the information in the original document. Use `ResolveDIDRaw()` to get the full original JSON. 17 22 func (d *BaseDirectory) ResolveDID(ctx context.Context, did syntax.DID) (*DIDDocument, error) { 23 + b, err := d.resolveDIDBytes(ctx, did) 24 + if err != nil { 25 + return nil, err 26 + } 27 + 28 + var doc DIDDocument 29 + if err := json.Unmarshal(b, &doc); err != nil { 30 + return nil, fmt.Errorf("%w: JSON DID document parse: %w", ErrDIDResolutionFailed, err) 31 + } 32 + if doc.DID != did { 33 + return nil, fmt.Errorf("document ID did not match DID") 34 + } 35 + return &doc, nil 36 + } 37 + 38 + // Low-level method for resolving a DID to a raw JSON document. 39 + // 40 + // This method does not parse the DID document into an atproto-specific format, and does not bi-directionally verify handles. Most atproto-specific code should use the "Lookup*" methods, which do implement that functionality. 41 + func (d *BaseDirectory) ResolveDIDRaw(ctx context.Context, did syntax.DID) (json.RawMessage, error) { 42 + b, err := d.resolveDIDBytes(ctx, did) 43 + if err != nil { 44 + return nil, err 45 + } 46 + 47 + // parse as doc, to validate at least some syntax 48 + var doc DIDDocument 49 + if err := json.Unmarshal(b, &doc); err != nil { 50 + return nil, fmt.Errorf("%w: JSON DID document parse: %w", ErrDIDResolutionFailed, err) 51 + } 52 + if doc.DID != did { 53 + return nil, fmt.Errorf("document ID did not match DID") 54 + } 55 + 56 + return json.RawMessage(b), nil 57 + } 58 + 59 + func (d *BaseDirectory) resolveDIDBytes(ctx context.Context, did syntax.DID) ([]byte, error) { 60 + var b []byte 61 + var err error 18 62 start := time.Now() 19 63 switch did.Method() { 20 64 case "web": 21 - doc, err := d.ResolveDIDWeb(ctx, did) 22 - elapsed := time.Since(start) 23 - slog.Debug("resolve DID", "did", did, "err", err, "duration_ms", elapsed.Milliseconds()) 24 - return doc, err 65 + b, err = d.resolveDIDWeb(ctx, did) 25 66 case "plc": 26 - doc, err := d.ResolveDIDPLC(ctx, did) 27 - elapsed := time.Since(start) 28 - slog.Debug("resolve DID", "did", did, "err", err, "duration_ms", elapsed.Milliseconds()) 29 - return doc, err 67 + b, err = d.resolveDIDPLC(ctx, did) 30 68 default: 31 69 return nil, fmt.Errorf("DID method not supported: %s", did.Method()) 32 70 } 71 + elapsed := time.Since(start) 72 + slog.Debug("resolve DID", "did", did, "err", err, "duration_ms", elapsed.Milliseconds()) 73 + return b, err 33 74 } 34 75 35 - func (d *BaseDirectory) ResolveDIDWeb(ctx context.Context, did syntax.DID) (*DIDDocument, error) { 76 + func (d *BaseDirectory) resolveDIDWeb(ctx context.Context, did syntax.DID) ([]byte, error) { 36 77 if did.Method() != "web" { 37 78 return nil, fmt.Errorf("expected a did:web, got: %s", did) 38 79 } ··· 77 118 return nil, fmt.Errorf("%w: did:web HTTP status %d", ErrDIDResolutionFailed, resp.StatusCode) 78 119 } 79 120 80 - var doc DIDDocument 81 - if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { 82 - return nil, fmt.Errorf("%w: JSON DID document parse: %w", ErrDIDResolutionFailed, err) 83 - } 84 - return &doc, nil 121 + return io.ReadAll(resp.Body) 85 122 } 86 123 87 - func (d *BaseDirectory) ResolveDIDPLC(ctx context.Context, did syntax.DID) (*DIDDocument, error) { 124 + func (d *BaseDirectory) resolveDIDPLC(ctx context.Context, did syntax.DID) ([]byte, error) { 88 125 if did.Method() != "plc" { 89 126 return nil, fmt.Errorf("expected a did:plc, got: %s", did) 90 127 } ··· 116 153 return nil, fmt.Errorf("%w: PLC directory status %d", ErrDIDResolutionFailed, resp.StatusCode) 117 154 } 118 155 119 - var doc DIDDocument 120 - if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { 121 - return nil, fmt.Errorf("%w: JSON DID document parse: %w", ErrDIDResolutionFailed, err) 122 - } 123 - return &doc, nil 156 + return io.ReadAll(resp.Body) 124 157 }
+5
atproto/identity/diddoc_text.go atproto/identity/diddoc_test.go
··· 42 42 hdl, err := id.DeclaredHandle() 43 43 assert.NoError(err) 44 44 assert.Equal("atproto.com", hdl.String()) 45 + 46 + // NOTE: doesn't work if 'id' was in long form 47 + if path != "testdata/did_plc_doc_legacy.json" { 48 + assert.Equal(doc, id.DIDDocument()) 49 + } 45 50 } 46 51 } 47 52
+7 -5
atproto/identity/directory.go
··· 10 10 "github.com/bluesky-social/indigo/atproto/syntax" 11 11 ) 12 12 13 - // API for doing account lookups by DID or handle, with bi-directional verification handled automatically. Almost all atproto services and clients should use an implementation of this interface instead of resolving handles or DIDs separately 13 + // Ergonomic interface for atproto identity lookup, by DID or handle. 14 14 // 15 - // 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. 15 + // The "Lookup" methods resolve identities (handle and DID), and return results in a compact, opinionated struct (`Identity`). They do bi-directional handle/DID verification by default. Clients and services should use these methods by default, instead of resolving handles or DIDs separately. 16 + // 17 + // Looking up a handle which fails to resolve, or don't match DID alsoKnownAs, returns an error. When looking up a DID, if the handle does not resolve back to the DID, the lookup succeeds and returns an `Identity` where the Handle is the special `handle.invalid` value. 16 18 // 17 19 // Some example implementations of this interface could be: 18 20 // - basic direct resolution on every call ··· 20 22 // - API client, which just makes requests to PDS (or other remote service) 21 23 // - client for shared network cache (eg, Redis) 22 24 type Directory interface { 23 - LookupHandle(ctx context.Context, h syntax.Handle) (*Identity, error) 24 - LookupDID(ctx context.Context, d syntax.DID) (*Identity, error) 25 - Lookup(ctx context.Context, i syntax.AtIdentifier) (*Identity, error) 25 + LookupHandle(ctx context.Context, handle syntax.Handle) (*Identity, error) 26 + LookupDID(ctx context.Context, did syntax.DID) (*Identity, error) 27 + Lookup(ctx context.Context, atid syntax.AtIdentifier) (*Identity, error) 26 28 27 29 // Flushes any cache of the indicated identifier. If directory is not using caching, can ignore this. 28 30 Purge(ctx context.Context, i syntax.AtIdentifier) error
+32 -2
atproto/identity/identity.go
··· 83 83 } 84 84 } 85 85 86 + // Helper to generate a DID document based on an identity. Note that there is flexibility around parsing, and this won't necessarily "round-trip" for every valid DID document. 87 + func (ident *Identity) DIDDocument() DIDDocument { 88 + doc := DIDDocument{ 89 + DID: ident.DID, 90 + AlsoKnownAs: ident.AlsoKnownAs, 91 + VerificationMethod: make([]DocVerificationMethod, len(ident.Keys)), 92 + Service: make([]DocService, len(ident.Services)), 93 + } 94 + i := 0 95 + for k, key := range ident.Keys { 96 + doc.VerificationMethod[i] = DocVerificationMethod{ 97 + ID: fmt.Sprintf("%s#%s", ident.DID, k), 98 + Type: key.Type, 99 + Controller: ident.DID.String(), 100 + PublicKeyMultibase: key.PublicKeyMultibase, 101 + } 102 + i += 1 103 + } 104 + i = 0 105 + for k, svc := range ident.Services { 106 + doc.Service[i] = DocService{ 107 + ID: fmt.Sprintf("#%s", k), 108 + Type: svc.Type, 109 + ServiceEndpoint: svc.URL, 110 + } 111 + i += 1 112 + } 113 + return doc 114 + } 115 + 86 116 // Identifies and parses the atproto repo signing public key, specifically, out of any keys in this identity's DID document. 87 117 // 88 118 // Returns [ErrKeyNotFound] if there is no such key. ··· 135 165 // 136 166 // The endpoint should be an HTTP URL with method, hostname, and optional port. It may or may not include path segments. 137 167 // 138 - // Returns an empty string if the serivce isn't found, or if the URL fails to parse. 168 + // Returns an empty string if the service isn't found, or if the URL fails to parse. 139 169 func (i *Identity) PDSEndpoint() string { 140 170 return i.GetServiceEndpoint("atproto_pds") 141 171 } ··· 144 174 // 145 175 // The endpoint should be an HTTP URL with method, hostname, and optional port. It may or may not include path segments. 146 176 // 147 - // Returns an empty string if the serivce isn't found, or if the URL fails to parse. 177 + // Returns an empty string if the service isn't found, or if the URL fails to parse. 148 178 func (i *Identity) GetServiceEndpoint(id string) string { 149 179 if i.Services == nil { 150 180 return ""
+29
atproto/identity/mock_directory.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "encoding/json" 5 6 "fmt" 6 7 7 8 "github.com/bluesky-social/indigo/atproto/syntax" ··· 14 15 } 15 16 16 17 var _ Directory = (*MockDirectory)(nil) 18 + var _ Resolver = (*MockDirectory)(nil) 17 19 18 20 func NewMockDirectory() MockDirectory { 19 21 return MockDirectory{ ··· 60 62 return d.LookupDID(ctx, did) 61 63 } 62 64 return nil, fmt.Errorf("at-identifier neither a Handle nor a DID") 65 + } 66 + 67 + func (d *MockDirectory) ResolveHandle(ctx context.Context, h syntax.Handle) (syntax.DID, error) { 68 + h = h.Normalize() 69 + did, ok := d.Handles[h] 70 + if !ok { 71 + return "", ErrHandleNotFound 72 + } 73 + return did, nil 74 + } 75 + 76 + func (d *MockDirectory) ResolveDID(ctx context.Context, did syntax.DID) (*DIDDocument, error) { 77 + ident, ok := d.Identities[did] 78 + if !ok { 79 + return nil, ErrDIDNotFound 80 + } 81 + doc := ident.DIDDocument() 82 + return &doc, nil 83 + } 84 + 85 + func (d *MockDirectory) ResolveDIDRaw(ctx context.Context, did syntax.DID) (json.RawMessage, error) { 86 + ident, ok := d.Identities[did] 87 + if !ok { 88 + return nil, ErrDIDNotFound 89 + } 90 + doc := ident.DIDDocument() 91 + return json.Marshal(doc) 63 92 } 64 93 65 94 func (d *MockDirectory) Purge(ctx context.Context, a syntax.AtIdentifier) error {
+14 -1
atproto/identity/mock_directory_test.go
··· 50 50 51 51 _, err = c.LookupHandle(ctx, syntax.HandleInvalid) 52 52 assert.ErrorIs(err, ErrHandleNotFound) 53 - out, err = c.LookupDID(ctx, syntax.DID("did:plc:abc999")) 53 + _, err = c.LookupDID(ctx, syntax.DID("did:plc:abc999")) 54 + assert.ErrorIs(err, ErrDIDNotFound) 55 + 56 + did, err := c.ResolveHandle(ctx, syntax.Handle("handle.example.com")) 57 + assert.NoError(err) 58 + assert.Equal(id1.DID, did) 59 + _, err = c.ResolveHandle(ctx, syntax.Handle("notfound.example.com")) 60 + assert.ErrorIs(err, ErrHandleNotFound) 61 + 62 + _, err = c.ResolveDID(ctx, syntax.DID("did:plc:abc222")) 63 + assert.NoError(err) 64 + // TODO: verify structure matches 65 + 66 + _, err = c.ResolveDID(ctx, syntax.DID("did:plc:abc999")) 54 67 assert.ErrorIs(err, ErrDIDNotFound) 55 68 }
+17
atproto/identity/resolver.go
··· 1 + package identity 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + ) 9 + 10 + // Low-level interface for resolving DIDs and atproto handles. 11 + // 12 + // Most atproto code should use the `identity.Directory` interface instead. 13 + type Resolver interface { 14 + ResolveDID(ctx context.Context, did syntax.DID) (*DIDDocument, error) 15 + ResolveDIDRaw(ctx context.Context, did syntax.DID) (json.RawMessage, error) 16 + ResolveHandle(ctx context.Context, handle syntax.Handle) (syntax.DID, error) 17 + }
+9 -11
cmd/goat/identity.go
··· 36 36 return err 37 37 } 38 38 dir := identity.BaseDirectory{} 39 - var doc *identity.DIDDocument 40 - 41 - if cctx.Bool("did") { 42 - if atid.IsDID() { 43 - } 44 - } 39 + var raw json.RawMessage 45 40 46 41 if atid.IsDID() { 47 42 did, err := atid.AsDID() ··· 52 47 fmt.Println(did) 53 48 return nil 54 49 } 55 - doc, err = dir.ResolveDID(ctx, did) 50 + raw, err = dir.ResolveDIDRaw(ctx, did) 56 51 if err != nil { 57 52 return err 58 53 } ··· 69 64 fmt.Println(did) 70 65 return nil 71 66 } 72 - doc, err = dir.ResolveDID(ctx, did) 67 + raw, err = dir.ResolveDIDRaw(ctx, did) 73 68 if err != nil { 74 69 return err 75 70 } 76 71 77 - ident := identity.ParseIdentity(doc) 72 + var doc identity.DIDDocument 73 + if err := json.Unmarshal(raw, &doc); err != nil { 74 + return err 75 + } 76 + ident := identity.ParseIdentity(&doc) 78 77 decl, err := ident.DeclaredHandle() 79 78 if err != nil { 80 79 return err ··· 84 83 } 85 84 } 86 85 87 - // TODO: actually print DID doc instead of JSON version of identity 88 - b, err := json.MarshalIndent(doc, "", " ") 86 + b, err := json.MarshalIndent(raw, "", " ") 89 87 if err != nil { 90 88 return err 91 89 }