Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,11 @@ binaries embed them at build time and present the session as a desktop client, s
<https://my.telegram.org>: `tg init --app-id APP_ID --app-hash APP_HASH`.

The config is written to the `gotd` subdirectory of your config dir, e.g.
`~/.config/gotd/gotd.cli.yaml`. The session persists there too, so subsequent
commands run headless.
`~/.config/gotd/gotd.cli.yaml`. The session persists too, so subsequent commands
run headless. On macOS the session is stored in the login Keychain by default; an
existing file session is migrated into the Keychain automatically on first use.
Set `keychain: false` in the config to keep it in a file alongside the config
instead (useful for headless macOS). Other platforms always use a file.

### Login options

Expand Down
10 changes: 5 additions & 5 deletions cmd/tg/accounts.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"fmt"
"io"
"os"
"path/filepath"

"github.com/go-faster/errors"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -50,21 +49,22 @@ func (a *app) newAccountsCmd() *cobra.Command {
Long: "List configured accounts (with auth status), or add/remove named accounts.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
dir := filepath.Dir(a.configPath)
def := a.cfg.resolvedDefault()
var res accountsResult
for _, label := range a.cfg.labels() {
acc, err := a.cfg.account(label)
if err != nil {
return err
}
sessionPath := acc.sessionPath(dir, label, kindUser)
_, statErr := os.Stat(sessionPath)
hasSession, err := a.sessionStore(label, acc, kindUser).Exists(cmd.Context())
if err != nil {
return errors.Wrapf(err, "check session for %s", label)
}
res.Accounts = append(res.Accounts, accountStatus{
Label: label,
AppID: acc.AppID,
HasBot: acc.BotToken != "",
HasSession: statErr == nil,
HasSession: hasSession,
Default: label == def,
})
}
Expand Down
12 changes: 4 additions & 8 deletions cmd/tg/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,13 @@ package main

import (
"context"
"path/filepath"

"github.com/go-faster/errors"
"github.com/spf13/cobra"
"go.uber.org/zap"

"github.com/gotd/contrib/middleware/floodwait"
"github.com/gotd/log/logzap"
"github.com/gotd/td/session"
"github.com/gotd/td/telegram"
"github.com/gotd/td/telegram/dcs"
"github.com/gotd/td/tg"
Expand Down Expand Up @@ -189,12 +187,10 @@ func (a *app) optionsFor(st *accountState, rp runParams, d tg.UpdateDispatcher)
}

opts := telegram.Options{
Logger: logzap.New(a.log.Named("tg")),
Device: deviceConfig(),
Middlewares: mw,
SessionStorage: &session.FileStorage{
Path: st.acc.sessionPath(filepath.Dir(a.configPath), st.label, rp.auth.String()),
},
Logger: logzap.New(a.log.Named("tg")),
Device: deviceConfig(),
Middlewares: mw,
SessionStorage: a.sessionStore(st.label, st.acc, rp.auth.String()),
}
if rp.updates {
opts.UpdateHandler = d
Expand Down
5 changes: 5 additions & 0 deletions cmd/tg/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ type Config struct {
// Empty means the top-level "default" account.
DefaultAccount string `yaml:"default_account,omitempty"`

// Keychain stores the session in the macOS Keychain instead of a file.
// nil (unset) defaults to on; set `keychain: false` to opt out (e.g. on
// headless macOS). Ignored off macOS, where sessions are always files.
Keychain *bool `yaml:"keychain,omitempty"`

// Accounts holds additional named accounts, usable via --account <label>.
Accounts map[string]Account `yaml:"accounts,omitempty"`
}
Expand Down
13 changes: 7 additions & 6 deletions cmd/tg/logout.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ files are removed even if it fails (e.g. the session is already invalid).`,
}
st := a.active
dir := filepath.Dir(a.configPath)
sessionPath := st.acc.sessionPath(dir, st.label, kind.String())
store := a.sessionStore(st.label, st.acc, kind.String())
cachePath := st.acc.peerCachePath(dir, st.label, kind.String())

// Best-effort server-side logout.
Expand All @@ -57,11 +57,12 @@ files are removed even if it fails (e.g. the session is already invalid).`,
_, _ = fmt.Fprintln(os.Stderr, "Warning: remote logout failed, removing local session anyway:", err)
}

// Remove local files regardless.
for _, p := range []string{sessionPath, cachePath} {
if rmErr := os.Remove(p); rmErr != nil && !os.IsNotExist(rmErr) {
return errors.Wrapf(rmErr, "remove %s", p)
}
// Remove the local session and peer cache regardless.
if rmErr := store.Delete(cmd.Context()); rmErr != nil {
return errors.Wrap(rmErr, "remove session")
}
if rmErr := os.Remove(cachePath); rmErr != nil && !os.IsNotExist(rmErr) {
return errors.Wrapf(rmErr, "remove %s", cachePath)
}
return a.printer.Emit(okResult{OK: true})
},
Expand Down
116 changes: 116 additions & 0 deletions cmd/tg/session_keychain_darwin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
//go:build darwin

package main

import (
"bytes"
"context"
"os"
"os/exec"

"github.com/go-faster/errors"

"github.com/gotd/td/session"
)

// keychainNotFound is the exit code `security` returns when an item is absent
// (errSecItemNotFound).
const keychainNotFound = 44

// keychainSessionStore returns a Keychain-backed store. The bool is always true
// on darwin; the signature mirrors the non-darwin stub so newSessionStore can
// fall back to a file when Keychain is unavailable. legacy is the pre-Keychain
// file session path, migrated into the Keychain (and removed) on first use.
func keychainSessionStore(service, account, legacy string) (sessionStore, bool) {
return &keychainStore{service: service, account: account, legacy: legacy}, true
}

// keychainStore stores a gotd string session as a generic password in the macOS
// login Keychain, driven through the `security` CLI (no cgo, keeps the binary
// static).
type keychainStore struct {
service string
account string
legacy string // pre-Keychain file session, migrated then deleted.
}

func keychainAddArgs(service, account, secret string) []string {
// -U updates the item in place when it already exists.
return []string{"add-generic-password", "-U", "-s", service, "-a", account, "-w", secret}
}

func keychainFindArgs(service, account string) []string {
// -w prints only the password to stdout.
return []string{"find-generic-password", "-s", service, "-a", account, "-w"}
}

func keychainDeleteArgs(service, account string) []string {
return []string{"delete-generic-password", "-s", service, "-a", account}
}

// isNotFound reports whether err is `security` signalling a missing item.
func isNotFound(err error) bool {
var ee *exec.ExitError
return errors.As(err, &ee) && ee.ExitCode() == keychainNotFound
}

func (s *keychainStore) LoadSession(ctx context.Context) ([]byte, error) {
out, err := exec.CommandContext(ctx, "security", keychainFindArgs(s.service, s.account)...).Output()
switch {
case err == nil:
// security appends a trailing newline to the password it prints.
return bytes.TrimSuffix(out, []byte("\n")), nil
case !isNotFound(err):
return nil, errors.Wrap(err, "keychain find")
}
// Nothing in the Keychain: migrate a pre-Keychain file session if present.
data, rerr := os.ReadFile(s.legacy)
if errors.Is(rerr, os.ErrNotExist) {
return nil, session.ErrNotFound
}
if rerr != nil {
return nil, errors.Wrap(rerr, "read legacy session")
}
if err := s.StoreSession(ctx, data); err != nil {
return nil, err
}
return data, nil
}

func (s *keychainStore) StoreSession(ctx context.Context, data []byte) error {
if err := exec.CommandContext(ctx, "security", keychainAddArgs(s.service, s.account, string(data))...).Run(); err != nil {
return errors.Wrap(err, "keychain add")
}
// Any write supersedes a pre-Keychain file session; remove it best-effort.
if err := os.Remove(s.legacy); err != nil && !os.IsNotExist(err) {
return errors.Wrap(err, "remove legacy session")
}
return nil
}

func (s *keychainStore) Exists(ctx context.Context) (bool, error) {
err := exec.CommandContext(ctx, "security", keychainFindArgs(s.service, s.account)...).Run()
if err == nil {
return true, nil
}
if !isNotFound(err) {
return false, errors.Wrap(err, "keychain find")
}
// Not yet migrated: a leftover file session still counts.
if _, serr := os.Stat(s.legacy); serr == nil {
return true, nil
}
return false, nil
}

func (s *keychainStore) Delete(ctx context.Context) error {
err := exec.CommandContext(ctx, "security", keychainDeleteArgs(s.service, s.account)...).Run()
if err != nil && !isNotFound(err) {
return errors.Wrap(err, "keychain delete")
}
// Also drop any pre-Keychain file session.
if rerr := os.Remove(s.legacy); rerr != nil && !os.IsNotExist(rerr) {
return errors.Wrap(rerr, "remove legacy session")
}
return nil
}
107 changes: 107 additions & 0 deletions cmd/tg/session_keychain_darwin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
//go:build darwin

package main

import (
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"slices"
"testing"

"github.com/gotd/td/session"
)

func TestKeychainArgs(t *testing.T) {
if got := keychainAddArgs("svc", "acc", "secret"); !slices.Equal(got,
[]string{"add-generic-password", "-U", "-s", "svc", "-a", "acc", "-w", "secret"}) {
t.Fatalf("add args: %v", got)
}
if got := keychainFindArgs("svc", "acc"); !slices.Equal(got,
[]string{"find-generic-password", "-s", "svc", "-a", "acc", "-w"}) {
t.Fatalf("find args: %v", got)
}
if got := keychainDeleteArgs("svc", "acc"); !slices.Equal(got,
[]string{"delete-generic-password", "-s", "svc", "-a", "acc"}) {
t.Fatalf("delete args: %v", got)
}
}

// exitErr fabricates an *exec.ExitError with the given code by actually running
// a process that exits with it (portable, no unsafe).
func exitErr(t *testing.T, code int) error {
t.Helper()
err := exec.Command("sh", "-c", "exit "+itoa(code)).Run()
var ee *exec.ExitError
if !errors.As(err, &ee) || ee.ExitCode() != code {
t.Fatalf("could not fabricate exit %d: %v", code, err)
}
return err
}

func itoa(n int) string {
if n == 0 {
return "0"
}
var b []byte
for n > 0 {
b = append([]byte{byte('0' + n%10)}, b...)
n /= 10
}
return string(b)
}

// TestKeychainMigration exercises the real login Keychain (unique service,
// cleaned up). It is darwin-only and CI runs on Linux, so it never runs there.
func TestKeychainMigration(t *testing.T) {
ctx := context.Background()
service := "gotd.session-test-" + t.Name()
account := "migrate"
legacy := filepath.Join(t.TempDir(), "legacy.json")
store, _ := keychainSessionStore(service, account, legacy)
t.Cleanup(func() { _ = store.Delete(ctx) })

// A leftover file session should migrate on first load and be removed.
want := []byte(`{"v":1,"data":"abc=="}`)
if err := os.WriteFile(legacy, want, 0o600); err != nil {
t.Fatal(err)
}
if ok, err := store.Exists(ctx); err != nil || !ok {
t.Fatalf("Exists() with legacy file = %v, %v; want true, nil", ok, err)
}
got, err := store.LoadSession(ctx)
if err != nil || string(got) != string(want) {
t.Fatalf("LoadSession() = %q, %v; want %q, nil", got, err, want)
}
if _, err := os.Stat(legacy); !os.IsNotExist(err) {
t.Fatalf("legacy file should be removed after migration, stat err = %v", err)
}
// Now served from the Keychain, no file.
got, err = store.LoadSession(ctx)
if err != nil || string(got) != string(want) {
t.Fatalf("LoadSession() after migration = %q, %v; want %q, nil", got, err, want)
}
if err := store.Delete(ctx); err != nil {
t.Fatalf("Delete: %v", err)
}
if _, err := store.LoadSession(ctx); !errors.Is(err, session.ErrNotFound) {
t.Fatalf("LoadSession() after delete = %v; want ErrNotFound", err)
}
}

func TestIsNotFound(t *testing.T) {
if !isNotFound(exitErr(t, keychainNotFound)) {
t.Fatal("exit 44 should be not-found")
}
if isNotFound(exitErr(t, 1)) {
t.Fatal("exit 1 should not be not-found")
}
if isNotFound(nil) {
t.Fatal("nil should not be not-found")
}
if isNotFound(errors.New("plain")) {
t.Fatal("non-exit error should not be not-found")
}
}
9 changes: 9 additions & 0 deletions cmd/tg/session_keychain_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//go:build !darwin

package main

// keychainSessionStore reports that no Keychain backend exists off macOS, so
// newSessionStore falls back to file storage.
func keychainSessionStore(_, _, _ string) (sessionStore, bool) {
return nil, false
}
Loading