{"contents":"// git-credential-tangled is a git credential helper that bridges git's credential\n// protocol to AT Protocol service auth JWTs.\n//\n// Login opens a browser for AT Protocol OAuth authentication:\n//\n//\tgit-credential-tangled login # global default\n//\tgit-credential-tangled login --host knot.example.com # per-host\n//\tgit-credential-tangled login --host knot.example.com --repo did:plc:xxx/my-repo # per-repo\n//\tgit-credential-tangled login --app-password # manual app password\n//\n// Configure git to use this credential helper:\n//\n//\tgit config --global credential.helper tangled\n//\n// For per-repo credentials on the same knot server, enable path matching:\n//\n//\tgit config --global 'credential.https://knot.example.com.useHttpPath' true\n//\n// Sessions are stored at:\n//\n//\t~/.config/tangled/session.json (global default)\n//\t~/.config/tangled/hosts/{hostname}.json (per-host)\n//\t~/.config/tangled/repos/{hostname}/{did}/{repo-name}.json (per-repo)\n//\n// Lookup order: per-repo (if path available) → per-host → global.\npackage main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"flag\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n)\n\nconst configDirName = \".config/tangled\"\n\n// Session stores the user's AT Protocol session credentials.\ntype Session struct {\n\tDID string `json:\"did\"`\n\tHandle string `json:\"handle\"`\n\tPDS string `json:\"pds\"`\n\tAccessJwt string `json:\"accessJwt\"`\n\tRefreshJwt string `json:\"refreshJwt\"`\n\n\t// OAuth sessions store the DPoP key and token endpoint for refresh.\n\t// Nil for app-password sessions.\n\tDPoPKey *DPoPJWK `json:\"dpopKey,omitempty\"`\n\tTokenEndpoint string `json:\"tokenEndpoint,omitempty\"`\n\n\t// App-password sessions store the password for re-login if tokens expire.\n\tAppPassword string `json:\"appPassword,omitempty\"`\n}\n\nfunc main() {\n\tif len(os.Args) \u003c 2 {\n\t\tfmt.Fprintf(os.Stderr, \"usage: git-credential-tangled \u003cget|store|erase|login\u003e\\n\")\n\t\tos.Exit(1)\n\t}\n\n\tvar err error\n\tswitch os.Args[1] {\n\tcase \"get\":\n\t\terr = cmdGet()\n\tcase \"store\", \"erase\":\n\t\t// No-op — credentials are managed by the AT Protocol session\n\tcase \"login\":\n\t\terr = cmdLogin(os.Args[2:])\n\tdefault:\n\t\tfmt.Fprintf(os.Stderr, \"unknown command: %s\\n\", os.Args[1])\n\t\tos.Exit(1)\n\t}\n\n\tif err != nil {\n\t\tfmt.Fprintf(os.Stderr, \"git-credential-tangled: %v\\n\", err)\n\t\tos.Exit(1)\n\t}\n}\n\n// cmdGet handles the \"get\" credential helper command.\n// It reads the git credential protocol from stdin and outputs a JWT token\n// as the password that the server's BasicToBearer middleware will convert\n// to a Bearer token.\nfunc cmdGet() error {\n\tinput := parseCredentialInput(os.Stdin)\n\thost := input[\"host\"]\n\tif host == \"\" {\n\t\treturn fmt.Errorf(\"no host provided\")\n\t}\n\tpath := input[\"path\"] // only set when credential.useHttpPath = true\n\n\tsession, sessionFile, err := resolveSession(host, path)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"not logged in, run: git-credential-tangled login\")\n\t}\n\n\tknotDid := hostToDidWeb(host)\n\n\ttoken, err := requestServiceAuth(session, knotDid)\n\tif err != nil {\n\t\t// Try refreshing the access token\n\t\tif refreshErr := refreshSessionTokens(session); refreshErr != nil {\n\t\t\t// Last resort for app-password sessions: re-create session\n\t\t\tif session.AppPassword != \"\" {\n\t\t\t\tif reloginErr := reloginWithAppPassword(session); reloginErr != nil {\n\t\t\t\t\treturn fmt.Errorf(\"session expired, run: git-credential-tangled login\")\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\treturn fmt.Errorf(\"session expired, run: git-credential-tangled login\")\n\t\t\t}\n\t\t}\n\t\tif saveErr := saveSessionTo(session, sessionFile); saveErr != nil {\n\t\t\treturn fmt.Errorf(\"save refreshed session: %w\", saveErr)\n\t\t}\n\t\ttoken, err = requestServiceAuth(session, knotDid)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"get service auth: %w\", err)\n\t\t}\n\t}\n\n\t// Output in git credential helper format\n\t// Username must not contain colons (Basic Auth splits on first colon)\n\tfmt.Printf(\"username=tangled\\n\")\n\tfmt.Printf(\"password=%s\\n\", token)\n\treturn nil\n}\n\n// cmdLogin handles interactive login to store an AT Protocol session.\n// By default, opens a browser for OAuth login. Use --app-password for manual entry.\nfunc cmdLogin(args []string) error {\n\tfs := flag.NewFlagSet(\"login\", flag.ExitOnError)\n\thost := fs.String(\"host\", \"\", \"knot server hostname (for per-host login)\")\n\trepo := fs.String(\"repo\", \"\", \"repo path as {did}/{name} (for per-repo login, requires --host)\")\n\tappPassword := fs.Bool(\"app-password\", false, \"use app password instead of browser login\")\n\tfs.Parse(args)\n\n\tif *repo != \"\" \u0026\u0026 *host == \"\" {\n\t\treturn fmt.Errorf(\"--repo requires --host\")\n\t}\n\n\treader := bufio.NewReader(os.Stdin)\n\n\tfmt.Print(\"Handle or DID: \")\n\tidentifier, _ := reader.ReadString('\\n')\n\tidentifier = strings.TrimSpace(identifier)\n\tif identifier == \"\" {\n\t\treturn fmt.Errorf(\"identifier is required\")\n\t}\n\n\tvar session *Session\n\tvar err error\n\n\tif *appPassword {\n\t\tsession, err = loginWithAppPassword(reader, identifier)\n\t} else {\n\t\tsession, err = oauthLogin(identifier)\n\t}\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Save to the appropriate location\n\tvar dest string\n\tswitch {\n\tcase *repo != \"\":\n\t\tdest = repoSessionPath(*host, *repo)\n\tcase *host != \"\":\n\t\tdest = hostSessionPath(*host)\n\tdefault:\n\t\tdest = globalSessionPath()\n\t}\n\tif err := saveSessionTo(session, dest); err != nil {\n\t\treturn fmt.Errorf(\"save session: %w\", err)\n\t}\n\n\tfmt.Fprintf(os.Stderr, \"Logged in as %s (%s)\\n\", session.Handle, session.DID)\n\tswitch {\n\tcase *repo != \"\":\n\t\tfmt.Fprintf(os.Stderr, \"Session saved for %s/%s\\n\", *host, *repo)\n\t\tfmt.Fprintf(os.Stderr, \"\\nEnable per-repo matching:\\n git config --global 'credential.https://%s.useHttpPath' true\\n\", *host)\n\tcase *host != \"\":\n\t\tfmt.Fprintf(os.Stderr, \"Session saved for host %s\\n\", *host)\n\t}\n\tfmt.Fprintf(os.Stderr, \"\\nConfigure git:\\n git config --global credential.helper tangled\\n\")\n\treturn nil\n}\n\n// loginWithAppPassword handles the app-password login flow.\nfunc loginWithAppPassword(reader *bufio.Reader, identifier string) (*Session, error) {\n\tfmt.Print(\"App password: \")\n\tpassword, _ := reader.ReadString('\\n')\n\tpassword = strings.TrimSpace(password)\n\tif password == \"\" {\n\t\treturn nil, fmt.Errorf(\"password is required\")\n\t}\n\n\tfmt.Fprintf(os.Stderr, \"Resolving PDS for %s...\\n\", identifier)\n\tpdsURL, err := resolvePDS(identifier)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"resolve PDS: %w\", err)\n\t}\n\tfmt.Fprintf(os.Stderr, \"PDS: %s\\n\", pdsURL)\n\n\tsession, err := createSession(pdsURL, identifier, password)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsession.AppPassword = password\n\treturn session, nil\n}\n\n// parseCredentialInput reads git credential helper key=value input.\nfunc parseCredentialInput(r io.Reader) map[string]string {\n\tresult := make(map[string]string)\n\tscanner := bufio.NewScanner(r)\n\tfor scanner.Scan() {\n\t\tline := scanner.Text()\n\t\tif line == \"\" {\n\t\t\tbreak\n\t\t}\n\t\tif k, v, ok := strings.Cut(line, \"=\"); ok {\n\t\t\tresult[k] = v\n\t\t}\n\t}\n\treturn result\n}\n\n// hostToDidWeb converts a hostname (with optional port) to a did:web DID.\n// Per the did:web spec, colons are percent-encoded.\nfunc hostToDidWeb(host string) string {\n\thost = strings.ReplaceAll(host, \":\", \"%3A\")\n\treturn \"did:web:\" + host\n}\n\n// --- Session storage ---\n\nfunc configDir() string {\n\thome, _ := os.UserHomeDir()\n\treturn filepath.Join(home, configDirName)\n}\n\nfunc globalSessionPath() string {\n\treturn filepath.Join(configDir(), \"session.json\")\n}\n\nfunc hostSessionPath(host string) string {\n\treturn filepath.Join(configDir(), \"hosts\", host+\".json\")\n}\n\nfunc repoSessionPath(host, repoPath string) string {\n\treturn filepath.Join(configDir(), \"repos\", host, repoPath+\".json\")\n}\n\n// resolveSession finds the most specific session for the given host and path.\n// Lookup order: per-repo → per-host → global.\n// Returns the session and the file it was loaded from (for saving after refresh).\nfunc resolveSession(host, path string) (*Session, string, error) {\n\t// Try per-repo first (requires credential.useHttpPath = true)\n\tif host != \"\" \u0026\u0026 path != \"\" {\n\t\tp := repoSessionPath(host, path)\n\t\tif s, err := loadSessionFrom(p); err == nil {\n\t\t\treturn s, p, nil\n\t\t}\n\t}\n\t// Try per-host\n\tif host != \"\" {\n\t\tp := hostSessionPath(host)\n\t\tif s, err := loadSessionFrom(p); err == nil {\n\t\t\treturn s, p, nil\n\t\t}\n\t}\n\t// Fall back to global\n\tp := globalSessionPath()\n\ts, err := loadSessionFrom(p)\n\tif err != nil {\n\t\treturn nil, \"\", err\n\t}\n\treturn s, p, nil\n}\n\nfunc loadSessionFrom(path string) (*Session, error) {\n\tdata, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar s Session\n\tif err := json.Unmarshal(data, \u0026s); err != nil {\n\t\treturn nil, err\n\t}\n\treturn \u0026s, nil\n}\n\nfunc saveSessionTo(s *Session, path string) error {\n\tif err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {\n\t\treturn err\n\t}\n\tdata, err := json.MarshalIndent(s, \"\", \" \")\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn os.WriteFile(path, data, 0600)\n}\n\n// --- PDS API ---\n\n// requestServiceAuth gets a service auth JWT from the user's PDS.\n// Uses DPoP for OAuth sessions, plain Bearer for app-password sessions.\nfunc requestServiceAuth(session *Session, audience string) (string, error) {\n\tu := fmt.Sprintf(\"%s/xrpc/com.atproto.server.getServiceAuth?aud=%s\",\n\t\tsession.PDS, url.QueryEscape(audience))\n\n\tif session.DPoPKey != nil {\n\t\tclient, err := newDPoPClient(session.DPoPKey)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"init DPoP client: %w\", err)\n\t\t}\n\t\tresp, err := client.doWithDPoP(\"GET\", u, nil, session.AccessJwt)\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tdefer resp.Body.Close()\n\t\treturn decodeServiceAuthToken(resp)\n\t}\n\n\treq, err := http.NewRequest(\"GET\", u, nil)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treq.Header.Set(\"Authorization\", \"Bearer \"+session.AccessJwt)\n\tresp, err := http.DefaultClient.Do(req)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tdefer resp.Body.Close()\n\treturn decodeServiceAuthToken(resp)\n}\n\nfunc decodeServiceAuthToken(resp *http.Response) (string, error) {\n\tif resp.StatusCode != 200 {\n\t\tbody, _ := io.ReadAll(resp.Body)\n\t\treturn \"\", fmt.Errorf(\"PDS returned %d: %s\", resp.StatusCode, truncate(string(body), 200))\n\t}\n\tvar result struct {\n\t\tToken string `json:\"token\"`\n\t}\n\tif err := json.NewDecoder(resp.Body).Decode(\u0026result); err != nil {\n\t\treturn \"\", err\n\t}\n\treturn result.Token, nil\n}\n\n// refreshSessionTokens refreshes access/refresh tokens.\n// Uses OAuth token endpoint with DPoP for OAuth sessions,\n// or XRPC refreshSession for app-password sessions.\nfunc refreshSessionTokens(session *Session) error {\n\tif session.DPoPKey != nil \u0026\u0026 session.TokenEndpoint != \"\" {\n\t\treturn refreshOAuthTokens(session)\n\t}\n\treturn refreshAppPasswordTokens(session)\n}\n\nfunc refreshOAuthTokens(session *Session) error {\n\tclient, err := newDPoPClient(session.DPoPKey)\n\tif err != nil {\n\t\treturn err\n\t}\n\tbody := url.Values{\n\t\t\"client_id\": {\"http://localhost\"},\n\t\t\"grant_type\": {\"refresh_token\"},\n\t\t\"refresh_token\": {session.RefreshJwt},\n\t}\n\tresp, err := client.doWithDPoP(\"POST\", session.TokenEndpoint, body, \"\")\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != 200 {\n\t\trespBody, _ := io.ReadAll(resp.Body)\n\t\treturn fmt.Errorf(\"OAuth refresh failed (%d): %s\", resp.StatusCode, truncate(string(respBody), 200))\n\t}\n\tvar result struct {\n\t\tAccessToken string `json:\"access_token\"`\n\t\tRefreshToken string `json:\"refresh_token\"`\n\t}\n\tif err := json.NewDecoder(resp.Body).Decode(\u0026result); err != nil {\n\t\treturn err\n\t}\n\tsession.AccessJwt = result.AccessToken\n\tsession.RefreshJwt = result.RefreshToken\n\treturn nil\n}\n\nfunc refreshAppPasswordTokens(session *Session) error {\n\treq, err := http.NewRequest(\"POST\", session.PDS+\"/xrpc/com.atproto.server.refreshSession\", nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\treq.Header.Set(\"Authorization\", \"Bearer \"+session.RefreshJwt)\n\tresp, err := http.DefaultClient.Do(req)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != 200 {\n\t\tbody, _ := io.ReadAll(resp.Body)\n\t\treturn fmt.Errorf(\"refresh failed (%d): %s\", resp.StatusCode, truncate(string(body), 200))\n\t}\n\tvar result struct {\n\t\tAccessJwt string `json:\"accessJwt\"`\n\t\tRefreshJwt string `json:\"refreshJwt\"`\n\t}\n\tif err := json.NewDecoder(resp.Body).Decode(\u0026result); err != nil {\n\t\treturn err\n\t}\n\tsession.AccessJwt = result.AccessJwt\n\tsession.RefreshJwt = result.RefreshJwt\n\treturn nil\n}\n\n// reloginWithAppPassword creates a fresh session using the stored app password.\nfunc reloginWithAppPassword(session *Session) error {\n\tfresh, err := createSession(session.PDS, session.DID, session.AppPassword)\n\tif err != nil {\n\t\treturn err\n\t}\n\tsession.AccessJwt = fresh.AccessJwt\n\tsession.RefreshJwt = fresh.RefreshJwt\n\treturn nil\n}\n\n// createSession authenticates with the PDS and returns a new session.\nfunc createSession(pdsURL, identifier, password string) (*Session, error) {\n\tbody, _ := json.Marshal(map[string]string{\n\t\t\"identifier\": identifier,\n\t\t\"password\": password,\n\t})\n\n\tresp, err := http.Post(\n\t\tpdsURL+\"/xrpc/com.atproto.server.createSession\",\n\t\t\"application/json\",\n\t\tbytes.NewReader(body),\n\t)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"connect to PDS: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != 200 {\n\t\trespBody, _ := io.ReadAll(resp.Body)\n\t\treturn nil, fmt.Errorf(\"login failed (%d): %s\", resp.StatusCode, truncate(string(respBody), 200))\n\t}\n\n\tvar result struct {\n\t\tDID string `json:\"did\"`\n\t\tHandle string `json:\"handle\"`\n\t\tAccessJwt string `json:\"accessJwt\"`\n\t\tRefreshJwt string `json:\"refreshJwt\"`\n\t}\n\tif err := json.NewDecoder(resp.Body).Decode(\u0026result); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn \u0026Session{\n\t\tDID: result.DID,\n\t\tHandle: result.Handle,\n\t\tPDS: pdsURL,\n\t\tAccessJwt: result.AccessJwt,\n\t\tRefreshJwt: result.RefreshJwt,\n\t}, nil\n}\n\n// --- Identity resolution ---\n\n// resolvePDS determines the PDS endpoint for a handle or DID.\nfunc resolvePDS(identifier string) (string, error) {\n\tvar did string\n\tif strings.HasPrefix(identifier, \"did:\") {\n\t\tdid = identifier\n\t} else {\n\t\tresolved, err := resolveHandle(identifier)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"resolve handle %q: %w\", identifier, err)\n\t\t}\n\t\tdid = resolved\n\t}\n\treturn resolvePDSFromDID(did)\n}\n\n// resolveHandle resolves an AT Protocol handle to a DID.\nfunc resolveHandle(handle string) (string, error) {\n\t// Try HTTP well-known first\n\tresp, err := http.Get(fmt.Sprintf(\"https://%s/.well-known/atproto-did\", handle))\n\tif err == nil {\n\t\tbody, _ := io.ReadAll(resp.Body)\n\t\tresp.Body.Close()\n\t\tif resp.StatusCode == 200 {\n\t\t\tdid := strings.TrimSpace(string(body))\n\t\t\tif strings.HasPrefix(did, \"did:\") {\n\t\t\t\treturn did, nil\n\t\t\t}\n\t\t}\n\t}\n\n\t// Fall back to public API\n\tu := fmt.Sprintf(\"https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=%s\",\n\t\turl.QueryEscape(handle))\n\tresp, err = http.Get(u)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != 200 {\n\t\tbody, _ := io.ReadAll(resp.Body)\n\t\treturn \"\", fmt.Errorf(\"resolve handle failed (%d): %s\", resp.StatusCode, truncate(string(body), 200))\n\t}\n\n\tvar result struct {\n\t\tDID string `json:\"did\"`\n\t}\n\tif err := json.NewDecoder(resp.Body).Decode(\u0026result); err != nil {\n\t\treturn \"\", err\n\t}\n\treturn result.DID, nil\n}\n\n// resolvePDSFromDID resolves a DID document and extracts the #atproto_pds service endpoint.\nfunc resolvePDSFromDID(did string) (string, error) {\n\tvar docURL string\n\tswitch {\n\tcase strings.HasPrefix(did, \"did:plc:\"):\n\t\tdocURL = \"https://plc.directory/\" + did\n\tcase strings.HasPrefix(did, \"did:web:\"):\n\t\thost := strings.TrimPrefix(did, \"did:web:\")\n\t\thost = strings.ReplaceAll(host, \"%3A\", \":\")\n\t\tdocURL = \"https://\" + host + \"/.well-known/did.json\"\n\tdefault:\n\t\treturn \"\", fmt.Errorf(\"unsupported DID method: %s\", did)\n\t}\n\n\tresp, err := http.Get(docURL)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != 200 {\n\t\tbody, _ := io.ReadAll(resp.Body)\n\t\treturn \"\", fmt.Errorf(\"resolve DID %s failed (%d): %s\", did, resp.StatusCode, truncate(string(body), 200))\n\t}\n\n\tvar doc struct {\n\t\tService []struct {\n\t\t\tID string `json:\"id\"`\n\t\t\tServiceEndpoint string `json:\"serviceEndpoint\"`\n\t\t} `json:\"service\"`\n\t}\n\tif err := json.NewDecoder(resp.Body).Decode(\u0026doc); err != nil {\n\t\treturn \"\", err\n\t}\n\n\tfor _, svc := range doc.Service {\n\t\tif svc.ID == \"#atproto_pds\" {\n\t\t\treturn svc.ServiceEndpoint, nil\n\t\t}\n\t}\n\n\treturn \"\", fmt.Errorf(\"no #atproto_pds service in DID document for %s\", did)\n}\n\nfunc truncate(s string, max int) string {\n\tif len(s) \u003c= max {\n\t\treturn s\n\t}\n\treturn s[:max] + \"...\"\n}\n","path":"cmd/git-credential-tangled/main.go","ref":"main"}