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:
@@ -7,7 +7,7 @@ ARCHITECTURE.md for design details.
|
|||||||
|
|
||||||
## Current Status
|
## Current Status
|
||||||
|
|
||||||
**Phase:** Phase 2 in progress. Steps 9–13 complete, ready for Step 14.
|
**Phase:** Phase 2 in progress. Steps 9–14 complete, ready for Step 15.
|
||||||
|
|
||||||
**Last updated:** 2026-03-23
|
**Last updated:** 2026-03-23
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ Phase 2: gRPC Remote Sync.
|
|||||||
|
|
||||||
## Up Next
|
## Up Next
|
||||||
|
|
||||||
Step 14: SSH Key Auth.
|
Step 15: CLI Wiring + Prune.
|
||||||
|
|
||||||
## Known Issues / Decisions Deferred
|
## Known Issues / Decisions Deferred
|
||||||
|
|
||||||
@@ -74,3 +74,4 @@ Step 14: SSH Key Auth.
|
|||||||
| 2026-03-23 | 12 | gRPC server: 5 RPC handlers (push/pull manifest+blobs, prune), bufconn tests, store.List. |
|
| 2026-03-23 | 12 | gRPC server: 5 RPC handlers (push/pull manifest+blobs, prune), bufconn tests, store.List. |
|
||||||
| 2026-03-23 | 12b | Directory recursion in Add, mirror up/down commands, 7 tests. |
|
| 2026-03-23 | 12b | Directory recursion in Add, mirror up/down commands, 7 tests. |
|
||||||
| 2026-03-23 | 13 | Client library: Push, Pull, Prune with chunked blob streaming. 6 integration tests. |
|
| 2026-03-23 | 13 | Client library: Push, Pull, Prune with chunked blob streaming. 6 integration tests. |
|
||||||
|
| 2026-03-23 | 14 | SSH key auth: server interceptor (authorized_keys, signature verification), client PerRPCCredentials (ssh-agent/key file). 8 tests including auth integration. |
|
||||||
|
|||||||
@@ -148,10 +148,11 @@ Depends on Step 12.
|
|||||||
|
|
||||||
### Step 14: SSH Key Auth
|
### Step 14: SSH Key Auth
|
||||||
|
|
||||||
- [ ] `server/auth.go`: AuthInterceptor, parse authorized_keys, verify SSH signatures
|
- [x] `server/auth.go`: AuthInterceptor, parse authorized_keys, verify SSH signatures
|
||||||
- [ ] `client/auth.go`: LoadSigner (ssh-agent or key file), PerRPCCredentials
|
- [x] `client/auth.go`: LoadSigner (ssh-agent or key file), SSHCredentials (PerRPCCredentials)
|
||||||
- [ ] `server/auth_test.go`: in-memory ed25519 key pair, reject unauthenticated
|
- [x] `server/auth_test.go`: valid key, reject unauthenticated, reject unauthorized key, reject expired timestamp
|
||||||
- [ ] `client/auth_test.go`: metadata generation test
|
- [x] `client/auth_test.go`: metadata generation, no-transport-security
|
||||||
|
- [x] Integration tests: authenticated push/pull succeeds, unauthenticated is rejected
|
||||||
|
|
||||||
### Step 15: CLI Wiring + Prune
|
### Step 15: CLI Wiring + Prune
|
||||||
|
|
||||||
|
|||||||
124
client/auth.go
Normal file
124
client/auth.go
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
"golang.org/x/crypto/ssh/agent"
|
||||||
|
"google.golang.org/grpc/credentials"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SSHCredentials implements grpc.PerRPCCredentials using an SSH signer.
|
||||||
|
type SSHCredentials struct {
|
||||||
|
signer ssh.Signer
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSSHCredentials creates credentials from an SSH signer.
|
||||||
|
func NewSSHCredentials(signer ssh.Signer) *SSHCredentials {
|
||||||
|
return &SSHCredentials{signer: signer}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRequestMetadata signs a nonce+timestamp and returns auth metadata.
|
||||||
|
func (c *SSHCredentials) GetRequestMetadata(_ context.Context, _ ...string) (map[string]string, error) {
|
||||||
|
nonce := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(nonce); err != nil {
|
||||||
|
return nil, fmt.Errorf("generating nonce: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tsUnix := time.Now().Unix()
|
||||||
|
payload := buildPayload(nonce, tsUnix)
|
||||||
|
|
||||||
|
sig, err := c.signer.Sign(rand.Reader, payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("signing payload: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pubkey := c.signer.PublicKey()
|
||||||
|
pubkeyStr := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pubkey)))
|
||||||
|
|
||||||
|
return map[string]string{
|
||||||
|
"x-sgard-auth-nonce": base64.StdEncoding.EncodeToString(nonce),
|
||||||
|
"x-sgard-auth-timestamp": strconv.FormatInt(tsUnix, 10),
|
||||||
|
"x-sgard-auth-signature": base64.StdEncoding.EncodeToString(ssh.Marshal(sig)),
|
||||||
|
"x-sgard-auth-pubkey": pubkeyStr,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireTransportSecurity returns false — auth is via SSH signatures,
|
||||||
|
// not TLS. Transport security can be added separately.
|
||||||
|
func (c *SSHCredentials) RequireTransportSecurity() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that SSHCredentials implements the interface.
|
||||||
|
var _ credentials.PerRPCCredentials = (*SSHCredentials)(nil)
|
||||||
|
|
||||||
|
// buildPayload constructs nonce || timestamp (big-endian int64).
|
||||||
|
func buildPayload(nonce []byte, tsUnix int64) []byte {
|
||||||
|
payload := make([]byte, len(nonce)+8)
|
||||||
|
copy(payload, nonce)
|
||||||
|
for i := 7; i >= 0; i-- {
|
||||||
|
payload[len(nonce)+i] = byte(tsUnix & 0xff)
|
||||||
|
tsUnix >>= 8
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadSigner loads an SSH signer. Resolution order:
|
||||||
|
// 1. keyPath (if non-empty)
|
||||||
|
// 2. SSH agent (if SSH_AUTH_SOCK is set)
|
||||||
|
// 3. Default key paths: ~/.ssh/id_ed25519, ~/.ssh/id_rsa
|
||||||
|
func LoadSigner(keyPath string) (ssh.Signer, error) {
|
||||||
|
if keyPath != "" {
|
||||||
|
return loadSignerFromFile(keyPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try ssh-agent.
|
||||||
|
if sock := os.Getenv("SSH_AUTH_SOCK"); sock != "" {
|
||||||
|
conn, err := net.Dial("unix", sock)
|
||||||
|
if err == nil {
|
||||||
|
ag := agent.NewClient(conn)
|
||||||
|
signers, err := ag.Signers()
|
||||||
|
if err == nil && len(signers) > 0 {
|
||||||
|
return signers[0], nil
|
||||||
|
}
|
||||||
|
_ = conn.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try default key paths.
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("no SSH key found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range []string{"id_ed25519", "id_rsa"} {
|
||||||
|
path := home + "/.ssh/" + name
|
||||||
|
signer, err := loadSignerFromFile(path)
|
||||||
|
if err == nil {
|
||||||
|
return signer, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("no SSH key found (tried --ssh-key, agent, ~/.ssh/id_ed25519, ~/.ssh/id_rsa)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadSignerFromFile(path string) (ssh.Signer, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading key %s: %w", path, err)
|
||||||
|
}
|
||||||
|
signer, err := ssh.ParsePrivateKey(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing key %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return signer, nil
|
||||||
|
}
|
||||||
57
client/auth_test.go
Normal file
57
client/auth_test.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSSHCredentialsMetadata(t *testing.T) {
|
||||||
|
_, 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
creds := NewSSHCredentials(signer)
|
||||||
|
|
||||||
|
md, err := creds.GetRequestMetadata(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetRequestMetadata: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all required fields are present and non-empty.
|
||||||
|
for _, key := range []string{
|
||||||
|
"x-sgard-auth-nonce",
|
||||||
|
"x-sgard-auth-timestamp",
|
||||||
|
"x-sgard-auth-signature",
|
||||||
|
"x-sgard-auth-pubkey",
|
||||||
|
} {
|
||||||
|
val, ok := md[key]
|
||||||
|
if !ok || val == "" {
|
||||||
|
t.Errorf("missing or empty metadata key %s", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSHCredentialsNoTransportSecurity(t *testing.T) {
|
||||||
|
_, 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
creds := NewSSHCredentials(signer)
|
||||||
|
if creds.RequireTransportSecurity() {
|
||||||
|
t.Error("RequireTransportSecurity should be false")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ package client
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
"errors"
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
@@ -12,6 +14,7 @@ import (
|
|||||||
"github.com/kisom/sgard/garden"
|
"github.com/kisom/sgard/garden"
|
||||||
"github.com/kisom/sgard/server"
|
"github.com/kisom/sgard/server"
|
||||||
"github.com/kisom/sgard/sgardpb"
|
"github.com/kisom/sgard/sgardpb"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
"google.golang.org/grpc/test/bufconn"
|
"google.golang.org/grpc/test/bufconn"
|
||||||
@@ -206,3 +209,100 @@ func TestPrune(t *testing.T) {
|
|||||||
t.Errorf("removed %d blobs, want 1", removed)
|
t.Errorf("removed %d blobs, want 1", removed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAuthIntegration(t *testing.T) {
|
||||||
|
// Generate an ed25519 key pair.
|
||||||
|
_, 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
serverDir := t.TempDir()
|
||||||
|
serverGarden, err := garden.Init(serverDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("init server garden: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up server with auth interceptor.
|
||||||
|
auth := server.NewAuthInterceptorFromKeys([]ssh.PublicKey{signer.PublicKey()})
|
||||||
|
lis := bufconn.Listen(bufSize)
|
||||||
|
srv := grpc.NewServer(
|
||||||
|
grpc.UnaryInterceptor(auth.UnaryInterceptor()),
|
||||||
|
grpc.StreamInterceptor(auth.StreamInterceptor()),
|
||||||
|
)
|
||||||
|
sgardpb.RegisterGardenSyncServer(srv, server.New(serverGarden))
|
||||||
|
t.Cleanup(func() { srv.Stop() })
|
||||||
|
go func() { _ = srv.Serve(lis) }()
|
||||||
|
|
||||||
|
// Client with SSH credentials.
|
||||||
|
creds := NewSSHCredentials(signer)
|
||||||
|
conn, err := grpc.NewClient("passthrough:///bufconn",
|
||||||
|
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
|
||||||
|
return lis.Dial()
|
||||||
|
}),
|
||||||
|
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||||
|
grpc.WithPerRPCCredentials(creds),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dial: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = conn.Close() })
|
||||||
|
|
||||||
|
c := New(conn)
|
||||||
|
|
||||||
|
// Authenticated request should succeed.
|
||||||
|
_, err = c.Pull(context.Background(), serverGarden)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("authenticated Pull should succeed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthIntegrationRejectsUnauthenticated(t *testing.T) {
|
||||||
|
_, 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
serverDir := t.TempDir()
|
||||||
|
serverGarden, err := garden.Init(serverDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("init server garden: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
auth := server.NewAuthInterceptorFromKeys([]ssh.PublicKey{signer.PublicKey()})
|
||||||
|
lis := bufconn.Listen(bufSize)
|
||||||
|
srv := grpc.NewServer(
|
||||||
|
grpc.UnaryInterceptor(auth.UnaryInterceptor()),
|
||||||
|
grpc.StreamInterceptor(auth.StreamInterceptor()),
|
||||||
|
)
|
||||||
|
sgardpb.RegisterGardenSyncServer(srv, server.New(serverGarden))
|
||||||
|
t.Cleanup(func() { srv.Stop() })
|
||||||
|
go func() { _ = srv.Serve(lis) }()
|
||||||
|
|
||||||
|
// Client WITHOUT credentials.
|
||||||
|
conn, err := grpc.NewClient("passthrough:///bufconn",
|
||||||
|
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
|
||||||
|
return lis.Dial()
|
||||||
|
}),
|
||||||
|
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dial: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = conn.Close() })
|
||||||
|
|
||||||
|
c := New(conn)
|
||||||
|
|
||||||
|
_, err = c.Pull(context.Background(), serverGarden)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("unauthenticated Pull should fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
213
server/auth.go
Normal file
213
server/auth.go
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/metadata"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Metadata keys for auth.
|
||||||
|
metaNonce = "x-sgard-auth-nonce"
|
||||||
|
metaTimestamp = "x-sgard-auth-timestamp"
|
||||||
|
metaSignature = "x-sgard-auth-signature"
|
||||||
|
metaPubkey = "x-sgard-auth-pubkey"
|
||||||
|
|
||||||
|
// authWindow is how far the timestamp can deviate from server time.
|
||||||
|
authWindow = 5 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthInterceptor verifies SSH key signatures on gRPC requests.
|
||||||
|
type AuthInterceptor struct {
|
||||||
|
authorizedKeys map[string]ssh.PublicKey // keyed by fingerprint
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAuthInterceptor creates an interceptor from an authorized_keys file.
|
||||||
|
// The file uses the same format as ~/.ssh/authorized_keys.
|
||||||
|
func NewAuthInterceptor(path string) (*AuthInterceptor, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading authorized keys: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := make(map[string]ssh.PublicKey)
|
||||||
|
rest := data
|
||||||
|
for len(rest) > 0 {
|
||||||
|
var key ssh.PublicKey
|
||||||
|
key, _, _, rest, err = ssh.ParseAuthorizedKey(rest)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
fp := ssh.FingerprintSHA256(key)
|
||||||
|
keys[fp] = key
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(keys) == 0 {
|
||||||
|
return nil, fmt.Errorf("no valid keys found in %s", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &AuthInterceptor{authorizedKeys: keys}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAuthInterceptorFromKeys creates an interceptor from pre-parsed keys.
|
||||||
|
// Intended for testing.
|
||||||
|
func NewAuthInterceptorFromKeys(keys []ssh.PublicKey) *AuthInterceptor {
|
||||||
|
m := make(map[string]ssh.PublicKey, len(keys))
|
||||||
|
for _, k := range keys {
|
||||||
|
m[ssh.FingerprintSHA256(k)] = k
|
||||||
|
}
|
||||||
|
return &AuthInterceptor{authorizedKeys: m}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnaryInterceptor returns a gRPC unary server interceptor.
|
||||||
|
func (a *AuthInterceptor) UnaryInterceptor() grpc.UnaryServerInterceptor {
|
||||||
|
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
|
||||||
|
if err := a.verify(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return handler(ctx, req)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamInterceptor returns a gRPC stream server interceptor.
|
||||||
|
func (a *AuthInterceptor) StreamInterceptor() grpc.StreamServerInterceptor {
|
||||||
|
return func(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
|
||||||
|
if err := a.verify(ss.Context()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return handler(srv, ss)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AuthInterceptor) verify(ctx context.Context) error {
|
||||||
|
md, ok := metadata.FromIncomingContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return status.Error(codes.Unauthenticated, "missing metadata")
|
||||||
|
}
|
||||||
|
|
||||||
|
nonceB64 := mdFirst(md, metaNonce)
|
||||||
|
tsStr := mdFirst(md, metaTimestamp)
|
||||||
|
sigB64 := mdFirst(md, metaSignature)
|
||||||
|
pubkeyStr := mdFirst(md, metaPubkey)
|
||||||
|
|
||||||
|
if nonceB64 == "" || tsStr == "" || sigB64 == "" || pubkeyStr == "" {
|
||||||
|
return status.Error(codes.Unauthenticated, "missing auth metadata fields")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse timestamp and check window.
|
||||||
|
tsUnix, err := strconv.ParseInt(tsStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return status.Error(codes.Unauthenticated, "invalid timestamp")
|
||||||
|
}
|
||||||
|
ts := time.Unix(tsUnix, 0)
|
||||||
|
if time.Since(ts).Abs() > authWindow {
|
||||||
|
return status.Error(codes.Unauthenticated, "timestamp outside allowed window")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse public key and check authorization.
|
||||||
|
pubkey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pubkeyStr))
|
||||||
|
if err != nil {
|
||||||
|
return status.Error(codes.Unauthenticated, "invalid public key")
|
||||||
|
}
|
||||||
|
fp := ssh.FingerprintSHA256(pubkey)
|
||||||
|
authorized, ok := a.authorizedKeys[fp]
|
||||||
|
if !ok {
|
||||||
|
return status.Errorf(codes.PermissionDenied, "key %s not authorized", fp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode nonce and signature.
|
||||||
|
nonce, err := base64.StdEncoding.DecodeString(nonceB64)
|
||||||
|
if err != nil {
|
||||||
|
return status.Error(codes.Unauthenticated, "invalid nonce encoding")
|
||||||
|
}
|
||||||
|
|
||||||
|
sigBytes, err := base64.StdEncoding.DecodeString(sigB64)
|
||||||
|
if err != nil {
|
||||||
|
return status.Error(codes.Unauthenticated, "invalid signature encoding")
|
||||||
|
}
|
||||||
|
|
||||||
|
sig, err := parseSSHSignature(sigBytes)
|
||||||
|
if err != nil {
|
||||||
|
return status.Error(codes.Unauthenticated, "invalid signature format")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the signed payload: nonce + timestamp bytes.
|
||||||
|
payload := buildPayload(nonce, tsUnix)
|
||||||
|
|
||||||
|
// Verify.
|
||||||
|
if err := authorized.Verify(payload, sig); err != nil {
|
||||||
|
return status.Error(codes.Unauthenticated, "signature verification failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildPayload constructs the message that is signed: nonce || timestamp (big-endian int64).
|
||||||
|
func buildPayload(nonce []byte, tsUnix int64) []byte {
|
||||||
|
payload := make([]byte, len(nonce)+8)
|
||||||
|
copy(payload, nonce)
|
||||||
|
for i := 7; i >= 0; i-- {
|
||||||
|
payload[len(nonce)+i] = byte(tsUnix & 0xff)
|
||||||
|
tsUnix >>= 8
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateNonce creates a 32-byte random nonce.
|
||||||
|
func GenerateNonce() ([]byte, error) {
|
||||||
|
nonce := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(nonce); err != nil {
|
||||||
|
return nil, fmt.Errorf("generating nonce: %w", err)
|
||||||
|
}
|
||||||
|
return nonce, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mdFirst(md metadata.MD, key string) string {
|
||||||
|
vals := md.Get(key)
|
||||||
|
if len(vals) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return vals[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSSHSignature deserializes an SSH signature from its wire format.
|
||||||
|
func parseSSHSignature(data []byte) (*ssh.Signature, error) {
|
||||||
|
if len(data) < 4 {
|
||||||
|
return nil, fmt.Errorf("signature too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSH signature wire format: string format, string blob
|
||||||
|
formatLen := int(data[0])<<24 | int(data[1])<<16 | int(data[2])<<8 | int(data[3])
|
||||||
|
if 4+formatLen > len(data) {
|
||||||
|
return nil, fmt.Errorf("invalid format length")
|
||||||
|
}
|
||||||
|
format := string(data[4 : 4+formatLen])
|
||||||
|
|
||||||
|
rest := data[4+formatLen:]
|
||||||
|
if len(rest) < 4 {
|
||||||
|
return nil, fmt.Errorf("missing blob length")
|
||||||
|
}
|
||||||
|
blobLen := int(rest[0])<<24 | int(rest[1])<<16 | int(rest[2])<<8 | int(rest[3])
|
||||||
|
if 4+blobLen > len(rest) {
|
||||||
|
return nil, fmt.Errorf("invalid blob length")
|
||||||
|
}
|
||||||
|
blob := rest[4 : 4+blobLen]
|
||||||
|
|
||||||
|
_ = strings.TrimSpace(format) // ensure format is clean
|
||||||
|
|
||||||
|
return &ssh.Signature{
|
||||||
|
Format: format,
|
||||||
|
Blob: blob,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
119
server/auth_test.go
Normal file
119
server/auth_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user