diff --git a/cli/main.go b/cli/main.go index 6093109..dbfaeb5 100644 --- a/cli/main.go +++ b/cli/main.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "log" "net" "net/http" @@ -16,6 +17,7 @@ import ( "strings" "time" + "github.com/bluesky-social/indigo/atproto/atclient" "github.com/bluesky-social/indigo/atproto/auth/oauth" "github.com/bluesky-social/indigo/atproto/syntax" "github.com/spf13/cobra" @@ -54,7 +56,16 @@ func main() { }, } - rootCmd.AddCommand(loginCmd, meCmd, createCmd) + checkCmd := &cobra.Command{ + Use: "check ", + Short: "Check vouch paths to a user", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return check(cmd.Context(), args[0]) + }, + } + + rootCmd.AddCommand(loginCmd, meCmd, createCmd, checkCmd) if err := rootCmd.ExecuteContext(context.Background()); err != nil { os.Exit(1) @@ -219,6 +230,273 @@ func create(ctx context.Context, handle string) error { return nil } +func check(ctx context.Context, handle string) error { + session, err := resumeSession(ctx) + if err != nil { + return err + } + + client := session.APIClient() + myDID := session.Data.AccountDID.String() + + // Resolve target handle to DID + targetDID, err := slingshotResolveHandle(handle) + if err != nil { + return fmt.Errorf("resolving handle %q: %w", handle, err) + } + + fmt.Printf("Checking vouch paths to %s (%s)...\n", handle, targetDID) + + // Fetch my vouches (people I vouch for) + myVouches, err := listVouchSubjects(ctx, client, myDID) + if err != nil { + return fmt.Errorf("fetching your vouches: %w", err) + } + + // Direct vouch check (depth 1) + for _, did := range myVouches { + if did == targetDID { + fmt.Printf("\nyou -> %s\n", handle) + return nil + } + } + + // Build reverse graph from target using microcosm (up to 3 levels back) + // reverseGraph[did] = set of DIDs that vouch for did + reverseGraph := make(map[string]map[string]bool) + + // Level 1: who vouches for target + level1, err := fetchVouchersFromMicrocosm(targetDID) + if err != nil { + return fmt.Errorf("querying microcosm: %w", err) + } + reverseGraph[targetDID] = toSet(level1) + + // Level 2: who vouches for each level-1 voucher + level2DIDs := []string{} + for _, did := range level1 { + vouchers, err := fetchVouchersFromMicrocosm(did) + if err != nil { + return fmt.Errorf("querying microcosm: %w", err) + } + reverseGraph[did] = toSet(vouchers) + level2DIDs = append(level2DIDs, vouchers...) + } + + // Level 3: who vouches for each level-2 voucher + for _, did := range level2DIDs { + if _, exists := reverseGraph[did]; exists { + continue // already fetched + } + vouchers, err := fetchVouchersFromMicrocosm(did) + if err != nil { + return fmt.Errorf("querying microcosm: %w", err) + } + reverseGraph[did] = toSet(vouchers) + } + + // Find all paths: me -> (someone I vouch for) -> ... -> target + // A path me -> A -> B -> target means: + // I vouch for A, A vouches for B, B vouches for target + // In reverseGraph terms: B is in reverseGraph[target], A is in reverseGraph[B] + myVouchSet := toSet(myVouches) + var paths [][]string + + // Depth 2: me -> X -> target (X vouches for target, I vouch for X) + for voucher := range reverseGraph[targetDID] { + if myVouchSet[voucher] { + paths = append(paths, []string{myDID, voucher, targetDID}) + } + } + + // Depth 3: me -> X -> Y -> target (Y vouches for target, X vouches for Y, I vouch for X) + for yDID := range reverseGraph[targetDID] { + for xDID := range reverseGraph[yDID] { + if myVouchSet[xDID] { + paths = append(paths, []string{myDID, xDID, yDID, targetDID}) + } + } + } + + if len(paths) == 0 { + fmt.Println("no vouch routes found") + return nil + } + + // Resolve all unique DIDs to handles for display + uniqueDIDs := make(map[string]bool) + for _, path := range paths { + for _, did := range path { + uniqueDIDs[did] = true + } + } + + handleMap := make(map[string]string) + handleMap[targetDID] = handle // we already know this one + for did := range uniqueDIDs { + if _, exists := handleMap[did]; exists { + continue + } + resolved, err := slingshotResolveDidToHandle(did) + if err != nil { + handleMap[did] = did // fallback to DID + } else { + handleMap[did] = resolved + } + } + + fmt.Printf("\nFound %d vouch route(s):\n", len(paths)) + for _, path := range paths { + parts := make([]string, len(path)) + for i, did := range path { + parts[i] = handleMap[did] + } + fmt.Println(strings.Join(parts, " -> ")) + } + + return nil +} + +// listVouchSubjects returns the DIDs that the given repo has vouched for. +func listVouchSubjects(ctx context.Context, client *atclient.APIClient, repo string) ([]string, error) { + var subjects []string + var cursor string + + for { + params := map[string]any{ + "repo": repo, + "collection": "dev.atvouch.graph.vouch", + "limit": 100, + } + if cursor != "" { + params["cursor"] = cursor + } + + var resp struct { + Records []struct { + Value struct { + Subject string `json:"subject"` + } `json:"value"` + } `json:"records"` + Cursor *string `json:"cursor"` + } + + if err := client.Get(ctx, "com.atproto.repo.listRecords", params, &resp); err != nil { + return nil, err + } + + for _, rec := range resp.Records { + if rec.Value.Subject != "" { + subjects = append(subjects, rec.Value.Subject) + } + } + + if resp.Cursor == nil || *resp.Cursor == "" { + break + } + cursor = *resp.Cursor + } + + return subjects, nil +} + +// fetchVouchersFromMicrocosm returns DIDs that have vouched for the given target DID. +func fetchVouchersFromMicrocosm(targetDID string) ([]string, error) { + u := "https://constellation.microcosm.blue/links/distinct-dids?" + url.Values{ + "target": {targetDID}, + "collection": {"dev.atvouch.graph.vouch"}, + "path": {".subject"}, + }.Encode() + + req, err := http.NewRequest("GET", u, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("microcosm returned %d: %s", resp.StatusCode, string(body)) + } + + var result struct { + LinkingDIDs []string `json:"linking_dids"` + } + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("parsing microcosm response: %w", err) + } + + return result.LinkingDIDs, nil +} + +// slingshotResolveHandle resolves a handle to a DID via slingshot. +func slingshotResolveHandle(handle string) (string, error) { + u := "https://slingshot.microcosm.blue/xrpc/com.atproto.identity.resolveHandle?" + url.Values{ + "handle": {handle}, + }.Encode() + + resp, err := http.Get(u) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return "", fmt.Errorf("slingshot returned %d", resp.StatusCode) + } + + var result struct { + DID string `json:"did"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", err + } + return result.DID, nil +} + +// slingshotResolveDidToHandle resolves a DID to a handle via slingshot. +func slingshotResolveDidToHandle(did string) (string, error) { + u := "https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?" + url.Values{ + "identifier": {did}, + }.Encode() + + resp, err := http.Get(u) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return "", fmt.Errorf("slingshot returned %d", resp.StatusCode) + } + + var result struct { + Handle string `json:"handle"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", err + } + return result.Handle, nil +} + +func toSet(items []string) map[string]bool { + s := make(map[string]bool, len(items)) + for _, item := range items { + s[item] = true + } + return s +} + func listenForCallback(ctx context.Context, res chan url.Values) (int, *http.Server, error) { listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil {