gotextlog

gotextlog
git clone https://git.ryansepassi.com/git/gotextlog.git
Log | Files | Refs | README

commit 9f1e7414b8559511c3792a25cf11b4ce7971bda3
parent 98e6cbbc37493950d845fcf988429899afab6dfd
Author: Ryan Sepassi <rsepassi@gmail.com>
Date:   Wed, 26 Aug 2026 05:16:42 -0700

Add SSH key login to TUI

Diffstat:
Minternal/tui/backend.go | 49+++++++++++++++++++++++++++++++++++++++++++++++--
Minternal/tui/backend_test.go | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/tui/model.go | 50++++++++++++++++++++++++++++++++++++++++++++++----
Minternal/tui/model_test.go | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/tui/view.go | 31+++++++++++++++++++++++++++----
5 files changed, 229 insertions(+), 10 deletions(-)

diff --git a/internal/tui/backend.go b/internal/tui/backend.go @@ -6,6 +6,7 @@ import ( "fmt" "git.ryansepassi.com/git/gotextlog.git/internal/config" + "git.ryansepassi.com/git/gotextlog.git/internal/sshkey" "git.ryansepassi.com/git/gotextlog.git/internal/textlog" ) @@ -20,6 +21,8 @@ const ( OpMe OperationKind = "me" OpRequestCode OperationKind = "request-code" OpVerifyCode OperationKind = "verify-code" + OpListKeys OperationKind = "list-keys" + OpKeyLogin OperationKind = "key-login" OpRevoke OperationKind = "revoke" OpUpdateBio OperationKind = "update-bio" OpCreatePost OperationKind = "create-post" @@ -39,6 +42,7 @@ type Operation struct { Limit, Page int Enabled bool Reason textlog.ReportReason + Key sshkey.Key } type ActivityEntry struct { @@ -62,6 +66,7 @@ type Result struct { Blocks []textlog.UserReference Me *textlog.CurrentUser Session *textlog.Session + Keys []sshkey.Key NextCursor string } @@ -70,9 +75,20 @@ type Backend interface { Firehose(context.Context) (<-chan textlog.Post, <-chan error) } -type clientBackend struct{ client *textlog.Client } +type clientBackend struct { + client *textlog.Client + keySource sshkey.Source + keySourceErr error +} + +func NewClientBackend(client *textlog.Client) Backend { + source, err := sshkey.NewDefaultSource() + return &clientBackend{client: client, keySource: source, keySourceErr: err} +} -func NewClientBackend(client *textlog.Client) Backend { return &clientBackend{client: client} } +func NewClientBackendWithKeySource(client *textlog.Client, source sshkey.Source) Backend { + return &clientBackend{client: client, keySource: source} +} func (b *clientBackend) Configure(cfg config.Config) { b.client.BaseURL = cfg.BaseURL b.client.Token = cfg.Token @@ -223,6 +239,35 @@ func (b *clientBackend) Execute(ctx context.Context, op Operation) (Result, erro b.client.Token = value.Data.Token } return Result{Session: &value.Data, Me: &value.Data.User}, err + case OpListKeys: + if b.keySourceErr != nil { + return Result{}, b.keySourceErr + } + if b.keySource == nil { + return Result{}, fmt.Errorf("SSH key source is unavailable") + } + keys, err := b.keySource.Keys(ctx) + return Result{Keys: keys}, err + case OpKeyLogin: + if b.keySourceErr != nil { + return Result{}, b.keySourceErr + } + if b.keySource == nil { + return Result{}, fmt.Errorf("SSH key source is unavailable") + } + challenge, err := b.client.RequestKeyChallenge(ctx, op.Key.PublicKey, op.Handle) + if err != nil { + return Result{}, err + } + signature, err := b.keySource.Sign(ctx, op.Key, challenge.Data.Message) + if err != nil { + return Result{}, err + } + value, err := b.client.VerifyKey(ctx, challenge.Data.ChallengeID, signature) + if err == nil { + b.client.Token = value.Data.Token + } + return Result{Session: &value.Data, Me: &value.Data.User}, err case OpRevoke: _, err := b.client.Revoke(ctx) if err == nil { diff --git a/internal/tui/backend_test.go b/internal/tui/backend_test.go @@ -9,6 +9,7 @@ import ( "reflect" "testing" + "git.ryansepassi.com/git/gotextlog.git/internal/sshkey" "git.ryansepassi.com/git/gotextlog.git/internal/textlog" ) @@ -59,3 +60,51 @@ func TestThreadBackendLoadsRootAndAllReplyPagesForFocusedReply(t *testing.T) { t.Fatalf("requests = %#v, want %#v", requests, want) } } + +type fixedKeySource struct { + keys []sshkey.Key + message []byte +} + +func (s *fixedKeySource) Keys(context.Context) ([]sshkey.Key, error) { return s.keys, nil } +func (s *fixedKeySource) Sign(_ context.Context, _ sshkey.Key, message []byte) ([]byte, error) { + s.message = append([]byte(nil), message...) + return []byte("signed-message"), nil +} + +func TestKeyLoginBackendRequestsSignsAndVerifiesChallenge(t *testing.T) { + var requests []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests = append(requests, r.URL.Path) + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v1/auth/key/challenge": + fmt.Fprint(w, `{"data":{"challenge_id":"challenge-1","message":"YXV0aC1tZXNzYWdl","expires_at":"2030-01-01T00:00:00Z","registered":false}}`) + case "/api/v1/auth/key/verify": + fmt.Fprint(w, `{"data":{"token":"key-token","expires_at":"2030-01-01T00:00:00Z","user":{"handle":"alice","can_post":true}}}`) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + source := &fixedKeySource{keys: []sshkey.Key{{Path: "/tmp/id_ed25519.pub", PublicKey: "ssh-ed25519 AAAA", Fingerprint: "SHA256:key"}}} + backend := NewClientBackendWithKeySource(textlog.NewClient(server.URL, "", server.Client()), source) + listed, err := backend.Execute(context.Background(), Operation{Kind: OpListKeys}) + if err != nil || len(listed.Keys) != 1 { + t.Fatalf("list keys: result=%#v err=%v", listed, err) + } + result, err := backend.Execute(context.Background(), Operation{Kind: OpKeyLogin, Key: source.keys[0], Handle: "alice"}) + if err != nil { + t.Fatal(err) + } + if result.Session == nil || result.Session.Token != "key-token" || result.Me == nil || result.Me.Handle != "alice" { + t.Fatalf("result=%#v", result) + } + if string(source.message) != "auth-message" { + t.Fatalf("signed message %q", source.message) + } + if want := []string{"/api/v1/auth/key/challenge", "/api/v1/auth/key/verify"}; !reflect.DeepEqual(requests, want) { + t.Fatalf("requests=%v want=%v", requests, want) + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go @@ -13,6 +13,7 @@ import ( "git.ryansepassi.com/git/gotextlog.git/internal/config" "git.ryansepassi.com/git/gotextlog.git/internal/domain" + "git.ryansepassi.com/git/gotextlog.git/internal/sshkey" "git.ryansepassi.com/git/gotextlog.git/internal/textlog" ) @@ -52,6 +53,7 @@ type screen struct { users, following, followers, blocks []textlog.UserReference tags, followingTags []textlog.TagReference replies []textlog.Reply + keys []sshkey.Key activeTab int parent, edit *textlog.Post preview bool @@ -267,7 +269,12 @@ func (m Model) applyLoaded(msg loadedMsg) (tea.Model, tea.Cmd) { case OpRequestCode: s.phase, s.query, s.input = "code", msg.op.Email, "" m.setStatus("Check your email for the six-digit code", false) - case OpVerifyCode: + case OpListKeys: + s.phase, s.keys, s.selected = "key", msg.result.Keys, 0 + if len(s.keys) == 0 { + m.setStatus("No Ed25519 SSH public keys found in ~/.ssh", true) + } + case OpVerifyCode, OpKeyLogin: if msg.result.Session != nil { m.me, m.config.Token = msg.result.Me, msg.result.Session.Token m.pop() @@ -699,14 +706,41 @@ func (m *Model) composeKey(key tea.KeyMsg) (tea.Model, tea.Cmd) { } func (m *Model) loginKey(key tea.KeyMsg) (tea.Model, tea.Cmd) { s := m.current() - if key.String() == "esc" { - if s.phase == "code" { + k := key.String() + if k == "tab" { + if s.phase == "email" || s.phase == "code" { + s.phase, s.input, s.err, s.loading = "key", "", "", true + return *m, m.command(s, Operation{Kind: OpListKeys}) + } + s.phase, s.input, s.err = "email", "", "" + return *m, nil + } + if k == "esc" { + switch s.phase { + case "code": s.phase, s.input = "email", "" - } else { + case "handle": + s.phase, s.input = "key", "" + default: m.pop() } return *m, nil } + if s.phase == "key" { + switch k { + case "j", "down": + s.selected = clamp(s.selected+1, len(s.keys)) + case "k", "up": + s.selected = clamp(s.selected-1, len(s.keys)) + case "enter": + if len(s.keys) == 0 { + m.setStatus("No Ed25519 SSH public keys found in ~/.ssh", true) + return *m, nil + } + s.phase, s.input = "handle", "" + } + return *m, nil + } if key.Type == tea.KeyEnter { if s.phase == "email" { if !strings.Contains(s.input, "@") || !strings.Contains(s.input, ".") { @@ -716,6 +750,14 @@ func (m *Model) loginKey(key tea.KeyMsg) (tea.Model, tea.Cmd) { s.loading = true return *m, m.command(s, Operation{Kind: OpRequestCode, Email: s.input}) } + if s.phase == "handle" { + if len(s.keys) == 0 { + m.setStatus("Select an Ed25519 SSH key", true) + return *m, nil + } + s.loading = true + return *m, m.command(s, Operation{Kind: OpKeyLogin, Key: s.keys[s.selected], Handle: strings.TrimSpace(s.input)}) + } validCode := len(s.input) == 6 for _, digit := range s.input { validCode = validCode && digit >= '0' && digit <= '9' diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go @@ -8,6 +8,7 @@ import ( "time" "git.ryansepassi.com/git/gotextlog.git/internal/config" + "git.ryansepassi.com/git/gotextlog.git/internal/sshkey" "git.ryansepassi.com/git/gotextlog.git/internal/textlog" "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" @@ -400,3 +401,62 @@ func TestAuthenticatedHeaderStaysOnOneLine(t *testing.T) { t.Fatalf("authenticated header height = %d, want 1", height) } } + +type keyLoginBackend struct { + acceptanceBackend + keys []sshkey.Key +} + +func (b *keyLoginBackend) Execute(ctx context.Context, op Operation) (Result, error) { + b.calls = append(b.calls, op) + switch op.Kind { + case OpListKeys: + return Result{Keys: b.keys}, nil + case OpKeyLogin: + user := textlog.CurrentUser{Handle: op.Handle, CanPost: true} + session := textlog.Session{Token: "key-token", User: user} + return Result{Session: &session, Me: &user}, nil + default: + return b.acceptanceBackend.Execute(ctx, op) + } +} + +func TestKeyLoginModeSelectsKeyRegistersAndSavesSession(t *testing.T) { + backend := &keyLoginBackend{keys: []sshkey.Key{ + {Path: "/home/me/.ssh/first.pub", PublicKey: "ssh-ed25519 first", Fingerprint: "SHA256:first"}, + {Path: "/home/me/.ssh/second.pub", PublicKey: "ssh-ed25519 second", Fingerprint: "SHA256:second"}, + }} + var saved config.Config + model := New(backend, config.Config{BaseURL: "https://example.test"}, Options{SaveConfig: func(cfg config.Config) error { + saved = cfg + return nil + }}) + model.openLogin() + + model, cmd := key(model, "tab") + model = settle(t, model, cmd) + if model.current().phase != "key" || len(model.current().keys) != 2 { + t.Fatalf("key mode did not list keys: %#v", model.current()) + } + model, _ = key(model, "j") + model, _ = key(model, "enter") + if model.current().phase != "handle" { + t.Fatalf("key selection did not request handle: %#v", model.current()) + } + model, _ = key(model, "alice") + model, cmd = key(model, "enter") + model = settle(t, model, cmd) + + if saved.Token != "key-token" || model.me == nil || model.me.Handle != "alice" || model.current().kind != screenFeed { + t.Fatalf("key login did not finish: saved=%#v me=%#v screen=%#v", saved, model.me, model.current()) + } + var login Operation + for _, op := range backend.calls { + if op.Kind == OpKeyLogin { + login = op + } + } + if login.Handle != "alice" || login.Key.Fingerprint != "SHA256:second" { + t.Fatalf("login operation=%#v", login) + } +} diff --git a/internal/tui/view.go b/internal/tui/view.go @@ -121,6 +121,9 @@ func (m *Model) footer(s *screen) string { if s.kind == screenCompose || (s.kind == screenAccount && s.phase == "bio") { return " Enter newline · Ctrl+S preview/submit · Esc cancel" } + if s.kind == screenLogin { + return " Enter continue · Tab email/SSH key · Esc back" + } if editingView(s) { return " Enter continue · Esc cancel" } @@ -161,11 +164,31 @@ func (m *Model) screenView(s *screen, p palette) string { case screenCompose: return m.composeView(s, p) case screenLogin: - label := "email address" - if s.phase == "code" { - label = "code sent to " + s.query + switch s.phase { + case "key": + if len(s.keys) == 0 { + return lipgloss.NewStyle().Foreground(p.muted).Padding(1).Render("No Ed25519 SSH public keys found in ~/.ssh") + } + lines := []string{"Select an Ed25519 SSH key:"} + for i, key := range s.keys { + marker := " " + if i == s.selected { + marker = "› " + } + lines = append(lines, marker+key.Path+" "+key.Fingerprint) + } + return lipgloss.NewStyle().Padding(1).Render(strings.Join(lines, "\n")) + case "handle": + key := s.keys[s.selected] + return lipgloss.NewStyle().Foreground(p.muted).Padding(0, 1).Render(key.Path+" "+key.Fingerprint) + "\n" + + editor("handle (required for first use)", s.input, false, false, m.width, p) + default: + label := "email address" + if s.phase == "code" { + label = "code sent to " + s.query + } + return editor(label, s.input, s.phase == "code", false, m.width, p) } - return editor(label, s.input, s.phase == "code", false, m.width, p) case screenAccount: return m.accountView(s, p) case screenSettings: