commit 98e6cbbc37493950d845fcf988429899afab6dfd
parent 018f7075aa1470e844f323371b8b6f68cf249a73
Author: Ryan Sepassi <rsepassi@gmail.com>
Date: Wed, 26 Aug 2026 05:13:08 -0700
Add SSH key authentication client
Diffstat:
7 files changed, 381 insertions(+), 3 deletions(-)
diff --git a/go.mod b/go.mod
@@ -7,6 +7,7 @@ require (
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/x/term v0.2.2
+ golang.org/x/crypto v0.43.0
)
require (
@@ -28,5 +29,5 @@ require (
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.38.0 // indirect
- golang.org/x/text v0.3.8 // indirect
+ golang.org/x/text v0.30.0 // indirect
)
diff --git a/go.sum b/go.sum
@@ -40,11 +40,15 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
+golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
+golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
-golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
-golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
+golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
+golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
+golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
+golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
diff --git a/internal/sshkey/source.go b/internal/sshkey/source.go
@@ -0,0 +1,156 @@
+package sshkey
+
+import (
+ "bytes"
+ "context"
+ "crypto/rand"
+ "errors"
+ "fmt"
+ "net"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "golang.org/x/crypto/ssh"
+ "golang.org/x/crypto/ssh/agent"
+)
+
+// Key identifies a local public key. PublicKey is the canonical OpenSSH
+// authorized-key representation without a comment.
+type Key struct {
+ Path string
+ PublicKey string
+ Fingerprint string
+}
+
+// Source separates TUI authentication from local key storage and signing.
+type Source interface {
+ Keys(context.Context) ([]Key, error)
+ Sign(context.Context, Key, []byte) ([]byte, error)
+}
+
+type LocalSource struct {
+ sshDir string
+ agentSocket string
+}
+
+func NewLocalSource(sshDir, agentSocket string) *LocalSource {
+ return &LocalSource{sshDir: sshDir, agentSocket: agentSocket}
+}
+
+func NewDefaultSource() (*LocalSource, error) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return nil, fmt.Errorf("find home directory: %w", err)
+ }
+ return NewLocalSource(filepath.Join(home, ".ssh"), os.Getenv("SSH_AUTH_SOCK")), nil
+}
+
+func (s *LocalSource) Keys(ctx context.Context) ([]Key, error) {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ paths, err := filepath.Glob(filepath.Join(s.sshDir, "*.pub"))
+ if err != nil {
+ return nil, fmt.Errorf("find SSH public keys: %w", err)
+ }
+ sort.Strings(paths)
+ keys := make([]Key, 0, len(paths))
+ seen := make(map[string]bool)
+ for _, path := range paths {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ contents, readErr := os.ReadFile(path)
+ if readErr != nil {
+ return nil, fmt.Errorf("read SSH public key %s: %w", path, readErr)
+ }
+ publicKey, _, _, _, parseErr := ssh.ParseAuthorizedKey(contents)
+ if parseErr != nil || publicKey.Type() != ssh.KeyAlgoED25519 {
+ continue
+ }
+ fingerprint := ssh.FingerprintSHA256(publicKey)
+ if seen[fingerprint] {
+ continue
+ }
+ seen[fingerprint] = true
+ keys = append(keys, Key{
+ Path: path,
+ PublicKey: strings.TrimSpace(string(ssh.MarshalAuthorizedKey(publicKey))),
+ Fingerprint: fingerprint,
+ })
+ }
+ return keys, nil
+}
+
+func (s *LocalSource) Sign(ctx context.Context, key Key, message []byte) ([]byte, error) {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ publicKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key.PublicKey))
+ if err != nil {
+ return nil, fmt.Errorf("parse selected SSH public key: %w", err)
+ }
+ if publicKey.Type() != ssh.KeyAlgoED25519 {
+ return nil, errors.New("selected SSH key is not Ed25519")
+ }
+
+ if s.agentSocket != "" {
+ signature, found, agentErr := s.signWithAgent(ctx, publicKey, message)
+ if agentErr == nil && found {
+ return ssh.Marshal(signature), nil
+ }
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ }
+
+ privatePath := strings.TrimSuffix(key.Path, ".pub")
+ privateBytes, err := os.ReadFile(privatePath)
+ if err != nil {
+ return nil, fmt.Errorf("selected key is not loaded in ssh-agent and read private key %s: %w", privatePath, err)
+ }
+ signer, err := ssh.ParsePrivateKey(privateBytes)
+ if err != nil {
+ var passphraseErr *ssh.PassphraseMissingError
+ if errors.As(err, &passphraseErr) {
+ return nil, errors.New("selected SSH key is encrypted; load it into ssh-agent")
+ }
+ return nil, fmt.Errorf("parse private key %s: %w", privatePath, err)
+ }
+ if !bytes.Equal(signer.PublicKey().Marshal(), publicKey.Marshal()) {
+ return nil, fmt.Errorf("private key %s does not match selected public key", privatePath)
+ }
+ signature, err := signer.Sign(rand.Reader, message)
+ if err != nil {
+ return nil, fmt.Errorf("sign authentication challenge: %w", err)
+ }
+ return ssh.Marshal(signature), nil
+}
+
+func (s *LocalSource) signWithAgent(ctx context.Context, publicKey ssh.PublicKey, message []byte) (*ssh.Signature, bool, error) {
+ connection, err := (&net.Dialer{}).DialContext(ctx, "unix", s.agentSocket)
+ if err != nil {
+ return nil, false, err
+ }
+ defer connection.Close()
+
+ signers, err := agent.NewClient(connection).Signers()
+ if err != nil {
+ return nil, false, err
+ }
+ for _, signer := range signers {
+ if !bytes.Equal(signer.PublicKey().Marshal(), publicKey.Marshal()) {
+ continue
+ }
+ signature, signErr := signer.Sign(rand.Reader, message)
+ if signErr != nil {
+ return nil, true, signErr
+ }
+ return signature, true, nil
+ }
+ return nil, false, nil
+}
+
+var _ Source = (*LocalSource)(nil)
diff --git a/internal/sshkey/source_test.go b/internal/sshkey/source_test.go
@@ -0,0 +1,121 @@
+package sshkey
+
+import (
+ "context"
+ "crypto/ed25519"
+ "crypto/rand"
+ "crypto/x509"
+ "encoding/pem"
+ "net"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "golang.org/x/crypto/ssh"
+ "golang.org/x/crypto/ssh/agent"
+)
+
+func TestLocalSourceDiscoversAndSignsWithSelectedEd25519Key(t *testing.T) {
+ directory := t.TempDir()
+ publicRaw, privateKey, err := ed25519.GenerateKey(rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ publicKey, err := ssh.NewPublicKey(publicRaw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ encodedPrivate, err := x509.MarshalPKCS8PrivateKey(privateKey)
+ if err != nil {
+ t.Fatal(err)
+ }
+ privatePath := filepath.Join(directory, "id_example")
+ if err := os.WriteFile(privatePath, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: encodedPrivate}), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ publicText := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(publicKey))) + " test@example\n"
+ if err := os.WriteFile(privatePath+".pub", []byte(publicText), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ // Discovery ignores public keys using unsupported algorithms.
+ if err := os.WriteFile(filepath.Join(directory, "id_rsa.pub"), []byte("ssh-rsa invalid"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ source := NewLocalSource(directory, "")
+ keys, err := source.Keys(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(keys) != 1 || keys[0].Path != privatePath+".pub" || keys[0].Fingerprint != ssh.FingerprintSHA256(publicKey) || keys[0].PublicKey != strings.TrimSpace(string(ssh.MarshalAuthorizedKey(publicKey))) {
+ t.Fatalf("keys = %#v", keys)
+ }
+
+ message := []byte("gotextlog-auth-v1\ntest")
+ encodedSignature, err := source.Sign(context.Background(), keys[0], message)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var signature ssh.Signature
+ if err := ssh.Unmarshal(encodedSignature, &signature); err != nil {
+ t.Fatal(err)
+ }
+ if err := publicKey.Verify(message, &signature); err != nil {
+ t.Fatalf("signature verification: %v", err)
+ }
+}
+func TestLocalSourceSignsWithAgentWhenPrivateFileIsUnavailable(t *testing.T) {
+ directory := t.TempDir()
+ publicRaw, privateKey, err := ed25519.GenerateKey(rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ publicKey, err := ssh.NewPublicKey(publicRaw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ publicPath := filepath.Join(directory, "agent-only.pub")
+ if err := os.WriteFile(publicPath, ssh.MarshalAuthorizedKey(publicKey), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ keyring := agent.NewKeyring()
+ if err := keyring.Add(agent.AddedKey{PrivateKey: privateKey}); err != nil {
+ t.Fatal(err)
+ }
+ socket := filepath.Join(t.TempDir(), "agent.sock")
+ listener, err := net.Listen("unix", socket)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer listener.Close()
+ go func() {
+ connection, acceptErr := listener.Accept()
+ if acceptErr == nil {
+ defer connection.Close()
+ _ = agent.ServeAgent(keyring, connection)
+ }
+ }()
+
+ source := NewLocalSource(directory, socket)
+ keys, err := source.Keys(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(keys) != 1 {
+ t.Fatalf("keys = %#v", keys)
+ }
+ message := []byte("agent challenge")
+ encodedSignature, err := source.Sign(context.Background(), keys[0], message)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var signature ssh.Signature
+ if err := ssh.Unmarshal(encodedSignature, &signature); err != nil {
+ t.Fatal(err)
+ }
+ if err := publicKey.Verify(message, &signature); err != nil {
+ t.Fatalf("signature verification: %v", err)
+ }
+}
diff --git a/internal/textlog/client.go b/internal/textlog/client.go
@@ -275,6 +275,26 @@ func (c *Client) VerifyCode(ctx context.Context, email, code string) (Envelope[S
return result, err
}
+func (c *Client) RequestKeyChallenge(ctx context.Context, publicKey, handle string) (Envelope[KeyChallenge], error) {
+ payload := struct {
+ PublicKey string `json:"public_key"`
+ Handle string `json:"handle,omitempty"`
+ }{PublicKey: publicKey, Handle: handle}
+ var result Envelope[KeyChallenge]
+ err := c.request(ctx, http.MethodPost, "/auth/key/challenge", payload, &result)
+ return result, err
+}
+
+func (c *Client) VerifyKey(ctx context.Context, challengeID string, signature []byte) (Envelope[Session], error) {
+ payload := struct {
+ ChallengeID string `json:"challenge_id"`
+ Signature []byte `json:"signature"`
+ }{ChallengeID: challengeID, Signature: signature}
+ var result Envelope[Session]
+ err := c.request(ctx, http.MethodPost, "/auth/key/verify", payload, &result)
+ return result, err
+}
+
func (c *Client) Revoke(ctx context.Context) (Envelope[RevokedResult], error) {
var result Envelope[RevokedResult]
err := c.request(ctx, http.MethodDelete, "/auth/session", nil, &result)
diff --git a/internal/textlog/client_test.go b/internal/textlog/client_test.go
@@ -2,6 +2,9 @@ package textlog
import (
"context"
+ "crypto/ed25519"
+ "crypto/rand"
+ "encoding/json"
"errors"
"fmt"
"io"
@@ -12,6 +15,8 @@ import (
"strings"
"testing"
"time"
+
+ "golang.org/x/crypto/ssh"
)
type expectedRequest struct {
@@ -330,6 +335,70 @@ func TestFirehoseReportsHTTPAndCancellationErrors(t *testing.T) {
})
}
+func TestClientKeyAuthenticationExchange(t *testing.T) {
+ publicRaw, privateKey, err := ed25519.GenerateKey(rand.Reader)
+ mustSucceed(t, err)
+ publicKey, err := ssh.NewPublicKey(publicRaw)
+ mustSucceed(t, err)
+ signer, err := ssh.NewSignerFromKey(privateKey)
+ mustSucceed(t, err)
+
+ message := []byte("gotextlog-auth-v1\nhttps://text.test\nchallenge")
+ expiresAt := time.Now().UTC().Add(time.Minute).Truncate(time.Second)
+ server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
+ writer.Header().Set("Content-Type", "application/json")
+ switch request.URL.Path {
+ case "/api/v1/auth/key/challenge":
+ var body struct {
+ PublicKey string `json:"public_key"`
+ Handle string `json:"handle"`
+ }
+ if request.Method != http.MethodPost || json.NewDecoder(request.Body).Decode(&body) != nil {
+ t.Errorf("invalid challenge request")
+ }
+ parsed, _, _, _, parseErr := ssh.ParseAuthorizedKey([]byte(body.PublicKey))
+ if parseErr != nil || string(parsed.Marshal()) != string(publicKey.Marshal()) || body.Handle != "alice" {
+ t.Errorf("challenge request = %#v, parse error = %v", body, parseErr)
+ }
+ _ = json.NewEncoder(writer).Encode(Envelope[KeyChallenge]{Data: KeyChallenge{
+ ChallengeID: "challenge-1", Message: message, ExpiresAt: expiresAt,
+ }})
+ case "/api/v1/auth/key/verify":
+ var body struct {
+ ChallengeID string `json:"challenge_id"`
+ Signature []byte `json:"signature"`
+ }
+ if request.Method != http.MethodPost || json.NewDecoder(request.Body).Decode(&body) != nil {
+ t.Errorf("invalid verify request")
+ }
+ var signature ssh.Signature
+ if body.ChallengeID != "challenge-1" || ssh.Unmarshal(body.Signature, &signature) != nil || publicKey.Verify(message, &signature) != nil {
+ t.Errorf("signature did not verify")
+ }
+ _ = json.NewEncoder(writer).Encode(Envelope[Session]{Data: Session{
+ Token: "session-token", ExpiresAt: expiresAt, User: CurrentUser{Handle: "alice", CanPost: true},
+ }})
+ default:
+ http.NotFound(writer, request)
+ }
+ }))
+ defer server.Close()
+
+ client := NewClient(server.URL, "", server.Client())
+ challenge, err := client.RequestKeyChallenge(context.Background(), strings.TrimSpace(string(ssh.MarshalAuthorizedKey(publicKey))), "alice")
+ mustSucceed(t, err)
+ if challenge.Data.ChallengeID != "challenge-1" || !reflect.DeepEqual(challenge.Data.Message, message) {
+ t.Fatalf("challenge = %#v", challenge.Data)
+ }
+ signature, err := signer.Sign(rand.Reader, challenge.Data.Message)
+ mustSucceed(t, err)
+ session, err := client.VerifyKey(context.Background(), challenge.Data.ChallengeID, ssh.Marshal(signature))
+ mustSucceed(t, err)
+ if session.Data.Token != "session-token" || session.Data.User.Handle != "alice" {
+ t.Fatalf("session = %#v", session.Data)
+ }
+}
+
func values(entries ...string) url.Values {
result := make(url.Values, len(entries)/2)
for index := 0; index < len(entries); index += 2 {
diff --git a/internal/textlog/types.go b/internal/textlog/types.go
@@ -121,6 +121,13 @@ type Session struct {
User CurrentUser `json:"user"`
}
+type KeyChallenge struct {
+ ChallengeID string `json:"challenge_id"`
+ Message []byte `json:"message"`
+ ExpiresAt time.Time `json:"expires_at"`
+ Registered bool `json:"registered"`
+}
+
type ReportReason string
const (