Step 14: SSH key auth for gRPC.

Server: AuthInterceptor parses authorized_keys, extracts SSH signature
from gRPC metadata (nonce + timestamp signed by client's SSH key),
verifies against authorized public keys with 5-minute timestamp window.

Client: SSHCredentials implements PerRPCCredentials, signs nonce+timestamp
per request. LoadSigner resolves key from flag, ssh-agent, or default paths.

8 tests: valid auth, reject unauthenticated, reject unauthorized key,
reject expired timestamp, metadata generation, plus 2 integration tests
(authenticated succeeds, unauthenticated rejected).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-23 23:58:09 -07:00
parent 525c3f0b4f
commit 4b841cdd82
7 changed files with 621 additions and 6 deletions

119
server/auth_test.go Normal file
View File

@@ -0,0 +1,119 @@
package server
import (
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"strconv"
"testing"
"time"
"golang.org/x/crypto/ssh"
"google.golang.org/grpc/metadata"
)
func generateTestKey(t *testing.T) (ssh.Signer, ssh.PublicKey) {
t.Helper()
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generating key: %v", err)
}
signer, err := ssh.NewSignerFromKey(priv)
if err != nil {
t.Fatalf("creating signer: %v", err)
}
return signer, signer.PublicKey()
}
func signedContext(t *testing.T, signer ssh.Signer) context.Context {
t.Helper()
nonce, err := GenerateNonce()
if err != nil {
t.Fatalf("generating nonce: %v", err)
}
tsUnix := time.Now().Unix()
payload := buildPayload(nonce, tsUnix)
sig, err := signer.Sign(rand.Reader, payload)
if err != nil {
t.Fatalf("signing: %v", err)
}
pubkeyStr := string(ssh.MarshalAuthorizedKey(signer.PublicKey()))
md := metadata.New(map[string]string{
metaNonce: base64.StdEncoding.EncodeToString(nonce),
metaTimestamp: strconv.FormatInt(tsUnix, 10),
metaSignature: base64.StdEncoding.EncodeToString(ssh.Marshal(sig)),
metaPubkey: pubkeyStr,
})
return metadata.NewIncomingContext(context.Background(), md)
}
func TestAuthVerifyValid(t *testing.T) {
signer, pubkey := generateTestKey(t)
interceptor := NewAuthInterceptorFromKeys([]ssh.PublicKey{pubkey})
ctx := signedContext(t, signer)
if err := interceptor.verify(ctx); err != nil {
t.Fatalf("verify should succeed: %v", err)
}
}
func TestAuthRejectUnauthenticated(t *testing.T) {
_, pubkey := generateTestKey(t)
interceptor := NewAuthInterceptorFromKeys([]ssh.PublicKey{pubkey})
// No metadata at all.
ctx := context.Background()
if err := interceptor.verify(ctx); err == nil {
t.Fatal("verify should reject missing metadata")
}
}
func TestAuthRejectUnauthorizedKey(t *testing.T) {
signer1, _ := generateTestKey(t)
_, pubkey2 := generateTestKey(t)
// Interceptor knows key2 but request is signed by key1.
interceptor := NewAuthInterceptorFromKeys([]ssh.PublicKey{pubkey2})
ctx := signedContext(t, signer1)
if err := interceptor.verify(ctx); err == nil {
t.Fatal("verify should reject unauthorized key")
}
}
func TestAuthRejectExpiredTimestamp(t *testing.T) {
signer, pubkey := generateTestKey(t)
interceptor := NewAuthInterceptorFromKeys([]ssh.PublicKey{pubkey})
nonce, err := GenerateNonce()
if err != nil {
t.Fatalf("generating nonce: %v", err)
}
// Timestamp 10 minutes ago — outside the 5-minute window.
tsUnix := time.Now().Add(-10 * time.Minute).Unix()
payload := buildPayload(nonce, tsUnix)
sig, err := signer.Sign(rand.Reader, payload)
if err != nil {
t.Fatalf("signing: %v", err)
}
pubkeyStr := string(ssh.MarshalAuthorizedKey(signer.PublicKey()))
md := metadata.New(map[string]string{
metaNonce: base64.StdEncoding.EncodeToString(nonce),
metaTimestamp: strconv.FormatInt(tsUnix, 10),
metaSignature: base64.StdEncoding.EncodeToString(ssh.Marshal(sig)),
metaPubkey: pubkeyStr,
})
ctx := metadata.NewIncomingContext(context.Background(), md)
if err := interceptor.verify(ctx); err == nil {
t.Fatal("verify should reject expired timestamp")
}
}