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:
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: