gotextlog

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

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:
Mgo.mod | 3++-
Mgo.sum | 8++++++--
Ainternal/sshkey/source.go | 156+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/sshkey/source_test.go | 121+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/textlog/client.go | 20++++++++++++++++++++
Minternal/textlog/client_test.go | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/textlog/types.go | 7+++++++
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 (