dev vouch dev on at. thats about it atvouch.dev
at appview 593 lines 15 kB view raw
1package main 2 3import ( 4 "bytes" 5 "context" 6 "encoding/json" 7 "errors" 8 "fmt" 9 "io" 10 "log" 11 "net" 12 "net/http" 13 "net/url" 14 "os" 15 "os/exec" 16 "runtime" 17 "strings" 18 "time" 19 20 "github.com/bluesky-social/indigo/atproto/atclient" 21 "github.com/bluesky-social/indigo/atproto/auth/oauth" 22 "github.com/bluesky-social/indigo/atproto/syntax" 23 "github.com/spf13/cobra" 24) 25 26func main() { 27 rootCmd := &cobra.Command{ 28 Use: "atvouch", 29 Short: "AT Protocol vouching tool", 30 } 31 32 loginCmd := &cobra.Command{ 33 Use: "login <handle>", 34 Short: "Authenticate with your PDS via OAuth", 35 Args: cobra.ExactArgs(1), 36 RunE: func(cmd *cobra.Command, args []string) error { 37 return login(cmd.Context(), args[0]) 38 }, 39 } 40 41 meCmd := &cobra.Command{ 42 Use: "me", 43 Short: "Show current authenticated session info", 44 Args: cobra.NoArgs, 45 RunE: func(cmd *cobra.Command, args []string) error { 46 return me(cmd.Context()) 47 }, 48 } 49 50 createCmd := &cobra.Command{ 51 Use: "create <handle>", 52 Short: "Vouch for a user by their handle", 53 Args: cobra.ExactArgs(1), 54 RunE: func(cmd *cobra.Command, args []string) error { 55 return create(cmd.Context(), args[0]) 56 }, 57 } 58 59 checkCmd := &cobra.Command{ 60 Use: "check <handle>", 61 Short: "Check vouch paths to a user", 62 Args: cobra.ExactArgs(1), 63 RunE: func(cmd *cobra.Command, args []string) error { 64 return check(cmd.Context(), args[0]) 65 }, 66 } 67 68 rootCmd.AddCommand(loginCmd, meCmd, createCmd, checkCmd) 69 70 if err := rootCmd.ExecuteContext(context.Background()); err != nil { 71 os.Exit(1) 72 } 73} 74 75func newStore() (*Store, error) { 76 return NewStore() 77} 78 79func newOAuthClient(store *Store, callbackURL string) *oauth.ClientApp { 80 config := oauth.NewLocalhostConfig(callbackURL, []string{ 81 "atproto", 82 "repo:dev.atvouch.graph.vouch", 83 }) 84 return oauth.NewClientApp(&config, store) 85} 86 87func login(ctx context.Context, handle string) error { 88 store, err := newStore() 89 if err != nil { 90 return err 91 } 92 93 // Start the callback server on a random available port 94 callbackCh := make(chan url.Values, 1) 95 port, server, err := listenForCallback(ctx, callbackCh) 96 if err != nil { 97 return err 98 } 99 defer server.Close() 100 101 callbackURL := fmt.Sprintf("http://127.0.0.1:%d/callback", port) 102 oauthClient := newOAuthClient(store, callbackURL) 103 104 // Start the OAuth flow 105 fmt.Printf("Logging in as %s...\n", handle) 106 authURL, err := oauthClient.StartAuthFlow(ctx, handle) 107 if err != nil { 108 return fmt.Errorf("starting auth flow: %w", err) 109 } 110 111 // Open the browser to the authorization URL 112 fmt.Printf("Opening browser...\n") 113 if !strings.HasPrefix(authURL, "https://") { 114 return fmt.Errorf("unexpected non-https auth URL") 115 } 116 if err := openBrowser(authURL); err != nil { 117 fmt.Printf("Could not open browser automatically.\nPlease visit: %s\n", authURL) 118 } 119 120 // Wait for the OAuth callback 121 fmt.Println("Waiting for authorization...") 122 params := <-callbackCh 123 124 // Exchange the authorization code for a session 125 sessData, err := oauthClient.ProcessCallback(ctx, params) 126 if err != nil { 127 return fmt.Errorf("processing callback: %w", err) 128 } 129 130 // Mark this as the active session 131 if err := store.SetActive(sessData.AccountDID, sessData.SessionID); err != nil { 132 return fmt.Errorf("saving active session: %w", err) 133 } 134 135 fmt.Printf("Logged in as %s (%s)\n", handle, sessData.AccountDID) 136 return nil 137} 138 139func resumeSession(ctx context.Context) (*oauth.ClientSession, error) { 140 store, err := newStore() 141 if err != nil { 142 return nil, err 143 } 144 145 active, err := store.GetActive() 146 if err != nil { 147 return nil, err 148 } 149 150 // We need a callback URL to construct the client config, but we won't 151 // actually start any auth flow here. Use a dummy port. 152 callbackURL := "http://127.0.0.1:0/callback" 153 oauthClient := newOAuthClient(store, callbackURL) 154 155 session, err := oauthClient.ResumeSession(ctx, active.DID, active.SessionID) 156 if err != nil { 157 return nil, fmt.Errorf("resuming session: %w", err) 158 } 159 160 return session, nil 161} 162 163func me(ctx context.Context) error { 164 session, err := resumeSession(ctx) 165 if err != nil { 166 return err 167 } 168 169 client := session.APIClient() 170 var resp json.RawMessage 171 if err := client.Get(ctx, "com.atproto.server.getSession", nil, &resp); err != nil { 172 return fmt.Errorf("fetching session: %w", err) 173 } 174 175 // Pretty-print the response 176 var pretty bytes.Buffer 177 json.Indent(&pretty, resp, "", " ") 178 fmt.Println(pretty.String()) 179 180 return nil 181} 182 183func create(ctx context.Context, handle string) error { 184 session, err := resumeSession(ctx) 185 if err != nil { 186 return err 187 } 188 189 client := session.APIClient() 190 191 // Resolve handle to DID 192 var resolveResp struct { 193 DID string `json:"did"` 194 } 195 if err := client.Get(ctx, "com.atproto.identity.resolveHandle", map[string]any{ 196 "handle": handle, 197 }, &resolveResp); err != nil { 198 return fmt.Errorf("resolving handle %q: %w", handle, err) 199 } 200 201 subjectDID, err := syntax.ParseDID(resolveResp.DID) 202 if err != nil { 203 return fmt.Errorf("invalid DID from resolution: %w", err) 204 } 205 206 // Check if a vouch already exists for this subject 207 var existingRecord struct { 208 URI string `json:"uri"` 209 Value any `json:"value"` 210 } 211 err = client.Get(ctx, "com.atproto.repo.getRecord", map[string]any{ 212 "repo": session.Data.AccountDID, 213 "collection": "dev.atvouch.graph.vouch", 214 "rkey": subjectDID.String(), 215 }, &existingRecord) 216 if err == nil { 217 fmt.Printf("You have already vouched for %s (%s)\n", handle, subjectDID) 218 fmt.Printf("Record: %s\n", existingRecord.URI) 219 return nil 220 } 221 222 // Create the vouch record 223 record := map[string]any{ 224 "$type": "dev.atvouch.graph.vouch", 225 "subject": subjectDID.String(), 226 "createdAt": time.Now().UTC().Format(time.RFC3339), 227 } 228 229 var createResp struct { 230 URI string `json:"uri"` 231 CID string `json:"cid"` 232 } 233 if err := client.Post(ctx, "com.atproto.repo.createRecord", map[string]any{ 234 "repo": session.Data.AccountDID, 235 "collection": "dev.atvouch.graph.vouch", 236 "rkey": subjectDID.String(), 237 "record": record, 238 }, &createResp); err != nil { 239 return fmt.Errorf("creating vouch record: %w", err) 240 } 241 242 fmt.Printf("Vouched for %s (%s)\n", handle, subjectDID) 243 fmt.Printf("Record: %s\n", createResp.URI) 244 245 return nil 246} 247 248// checkDeps holds injectable dependencies for the check logic. 249type checkDeps struct { 250 myDID string 251 resolveHandle func(handle string) (string, error) 252 resolveDidToHandle func(did string) (string, error) 253 fetchVouchers func(targetDID string) ([]string, error) 254 listMyVouches func() ([]string, error) 255} 256 257// checkResult holds the output of a check operation. 258type checkResult struct { 259 targetDID string 260 paths [][]string // each path is a list of DIDs 261 handleMap map[string]string // DID -> handle 262} 263 264func check(ctx context.Context, handle string) error { 265 session, err := resumeSession(ctx) 266 if err != nil { 267 return err 268 } 269 270 client := session.APIClient() 271 myDID := session.Data.AccountDID.String() 272 273 deps := checkDeps{ 274 myDID: myDID, 275 resolveHandle: slingshotResolveHandle, 276 resolveDidToHandle: slingshotResolveDidToHandle, 277 fetchVouchers: fetchVouchersFromMicrocosm, 278 listMyVouches: func() ([]string, error) { 279 return listVouchSubjects(ctx, client, myDID) 280 }, 281 } 282 283 result, err := checkWithDeps(handle, deps) 284 if err != nil { 285 return err 286 } 287 288 fmt.Printf("Checking vouch paths to %s (%s)...\n", handle, result.targetDID) 289 290 if result.paths == nil { 291 fmt.Printf("\nyou -> %s\n", handle) 292 return nil 293 } 294 295 if len(result.paths) == 0 { 296 fmt.Println("no vouch routes found") 297 return nil 298 } 299 300 fmt.Printf("\nFound %d vouch route(s):\n", len(result.paths)) 301 for _, path := range result.paths { 302 parts := make([]string, len(path)) 303 for i, did := range path { 304 parts[i] = result.handleMap[did] 305 } 306 fmt.Println(strings.Join(parts, " -> ")) 307 } 308 309 return nil 310} 311 312// checkWithDeps contains the core check logic with injected dependencies. 313// Returns a checkResult where paths == nil means direct vouch found, 314// paths == empty means no routes, otherwise contains discovered paths. 315func checkWithDeps(handle string, deps checkDeps) (*checkResult, error) { 316 targetDID, err := deps.resolveHandle(handle) 317 if err != nil { 318 return nil, fmt.Errorf("resolving handle %q: %w", handle, err) 319 } 320 321 myVouches, err := deps.listMyVouches() 322 if err != nil { 323 return nil, fmt.Errorf("fetching your vouches: %w", err) 324 } 325 326 // Direct vouch check (depth 1) 327 for _, did := range myVouches { 328 if did == targetDID { 329 return &checkResult{targetDID: targetDID, paths: nil}, nil 330 } 331 } 332 333 // Build reverse graph from target using microcosm (up to 3 levels back) 334 // reverseGraph[did] = set of DIDs that vouch for did 335 reverseGraph := make(map[string]map[string]bool) 336 337 // Level 1: who vouches for target 338 level1, err := deps.fetchVouchers(targetDID) 339 if err != nil { 340 return nil, fmt.Errorf("querying microcosm: %w", err) 341 } 342 reverseGraph[targetDID] = toSet(level1) 343 344 // Level 2: who vouches for each level-1 voucher 345 level2DIDs := []string{} 346 for _, did := range level1 { 347 vouchers, err := deps.fetchVouchers(did) 348 if err != nil { 349 return nil, fmt.Errorf("querying microcosm: %w", err) 350 } 351 reverseGraph[did] = toSet(vouchers) 352 level2DIDs = append(level2DIDs, vouchers...) 353 } 354 355 // Level 3: who vouches for each level-2 voucher 356 for _, did := range level2DIDs { 357 if _, exists := reverseGraph[did]; exists { 358 continue // already fetched 359 } 360 vouchers, err := deps.fetchVouchers(did) 361 if err != nil { 362 return nil, fmt.Errorf("querying microcosm: %w", err) 363 } 364 reverseGraph[did] = toSet(vouchers) 365 } 366 367 // Find all paths: me -> (someone I vouch for) -> ... -> target 368 myVouchSet := toSet(myVouches) 369 var paths [][]string 370 371 // Depth 2: me -> X -> target (X vouches for target, I vouch for X) 372 for voucher := range reverseGraph[targetDID] { 373 if myVouchSet[voucher] { 374 paths = append(paths, []string{deps.myDID, voucher, targetDID}) 375 } 376 } 377 378 // Depth 3: me -> X -> Y -> target (Y vouches for target, X vouches for Y, I vouch for X) 379 for yDID := range reverseGraph[targetDID] { 380 for xDID := range reverseGraph[yDID] { 381 if myVouchSet[xDID] { 382 paths = append(paths, []string{deps.myDID, xDID, yDID, targetDID}) 383 } 384 } 385 } 386 387 // Resolve all unique DIDs to handles for display 388 handleMap := make(map[string]string) 389 if len(paths) > 0 { 390 uniqueDIDs := make(map[string]bool) 391 for _, path := range paths { 392 for _, did := range path { 393 uniqueDIDs[did] = true 394 } 395 } 396 397 handleMap[targetDID] = handle // we already know this one 398 for did := range uniqueDIDs { 399 if _, exists := handleMap[did]; exists { 400 continue 401 } 402 resolved, err := deps.resolveDidToHandle(did) 403 if err != nil { 404 handleMap[did] = did // fallback to DID 405 } else { 406 handleMap[did] = resolved 407 } 408 } 409 } 410 411 return &checkResult{ 412 targetDID: targetDID, 413 paths: paths, 414 handleMap: handleMap, 415 }, nil 416} 417 418// listVouchSubjects returns the DIDs that the given repo has vouched for. 419func listVouchSubjects(ctx context.Context, client *atclient.APIClient, repo string) ([]string, error) { 420 var subjects []string 421 var cursor string 422 423 for { 424 params := map[string]any{ 425 "repo": repo, 426 "collection": "dev.atvouch.graph.vouch", 427 "limit": 100, 428 } 429 if cursor != "" { 430 params["cursor"] = cursor 431 } 432 433 var resp struct { 434 Records []struct { 435 Value struct { 436 Subject string `json:"subject"` 437 } `json:"value"` 438 } `json:"records"` 439 Cursor *string `json:"cursor"` 440 } 441 442 if err := client.Get(ctx, "com.atproto.repo.listRecords", params, &resp); err != nil { 443 return nil, err 444 } 445 446 for _, rec := range resp.Records { 447 if rec.Value.Subject != "" { 448 subjects = append(subjects, rec.Value.Subject) 449 } 450 } 451 452 if resp.Cursor == nil || *resp.Cursor == "" { 453 break 454 } 455 cursor = *resp.Cursor 456 } 457 458 return subjects, nil 459} 460 461// fetchVouchersFromMicrocosm returns DIDs that have vouched for the given target DID. 462func fetchVouchersFromMicrocosm(targetDID string) ([]string, error) { 463 u := "https://constellation.microcosm.blue/links/distinct-dids?" + url.Values{ 464 "target": {targetDID}, 465 "collection": {"dev.atvouch.graph.vouch"}, 466 "path": {".subject"}, 467 }.Encode() 468 469 req, err := http.NewRequest("GET", u, nil) 470 if err != nil { 471 return nil, err 472 } 473 req.Header.Set("Accept", "application/json") 474 475 resp, err := http.DefaultClient.Do(req) 476 if err != nil { 477 return nil, err 478 } 479 defer resp.Body.Close() 480 481 body, err := io.ReadAll(resp.Body) 482 if err != nil { 483 return nil, err 484 } 485 486 if resp.StatusCode != 200 { 487 return nil, fmt.Errorf("microcosm returned %d: %s", resp.StatusCode, string(body)) 488 } 489 490 var result struct { 491 LinkingDIDs []string `json:"linking_dids"` 492 } 493 if err := json.Unmarshal(body, &result); err != nil { 494 return nil, fmt.Errorf("parsing microcosm response: %w", err) 495 } 496 497 return result.LinkingDIDs, nil 498} 499 500// slingshotResolveHandle resolves a handle to a DID via slingshot. 501func slingshotResolveHandle(handle string) (string, error) { 502 u := "https://slingshot.microcosm.blue/xrpc/com.atproto.identity.resolveHandle?" + url.Values{ 503 "handle": {handle}, 504 }.Encode() 505 506 resp, err := http.Get(u) 507 if err != nil { 508 return "", err 509 } 510 defer resp.Body.Close() 511 512 if resp.StatusCode != 200 { 513 return "", fmt.Errorf("slingshot returned %d", resp.StatusCode) 514 } 515 516 var result struct { 517 DID string `json:"did"` 518 } 519 if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 520 return "", err 521 } 522 return result.DID, nil 523} 524 525// slingshotResolveDidToHandle resolves a DID to a handle via slingshot. 526func slingshotResolveDidToHandle(did string) (string, error) { 527 u := "https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?" + url.Values{ 528 "identifier": {did}, 529 }.Encode() 530 531 resp, err := http.Get(u) 532 if err != nil { 533 return "", err 534 } 535 defer resp.Body.Close() 536 537 if resp.StatusCode != 200 { 538 return "", fmt.Errorf("slingshot returned %d", resp.StatusCode) 539 } 540 541 var result struct { 542 Handle string `json:"handle"` 543 } 544 if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 545 return "", err 546 } 547 return result.Handle, nil 548} 549 550func toSet(items []string) map[string]bool { 551 s := make(map[string]bool, len(items)) 552 for _, item := range items { 553 s[item] = true 554 } 555 return s 556} 557 558func listenForCallback(ctx context.Context, res chan url.Values) (int, *http.Server, error) { 559 listener, err := net.Listen("tcp", "127.0.0.1:0") 560 if err != nil { 561 return 0, nil, err 562 } 563 564 mux := http.NewServeMux() 565 server := &http.Server{Handler: mux} 566 567 mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { 568 res <- r.URL.Query() 569 w.Header().Set("Content-Type", "text/html") 570 w.WriteHeader(200) 571 w.Write([]byte("<h1>Authorized! You can close this tab.</h1>")) 572 go server.Shutdown(ctx) 573 }) 574 575 go func() { 576 if err := server.Serve(listener); !errors.Is(err, http.ErrServerClosed) { 577 log.Fatal(err) 578 } 579 }() 580 581 return listener.Addr().(*net.TCPAddr).Port, server, nil 582} 583 584func openBrowser(url string) error { 585 switch runtime.GOOS { 586 case "darwin": 587 return exec.Command("open", url).Run() 588 case "windows": 589 return exec.Command("cmd", "/c", "start", url).Run() 590 default: 591 return exec.Command("xdg-open", url).Run() 592 } 593}