phase 4: connectrpc on the gateway authenticated edge

Replace the native-gRPC server bootstrap with a single
`connectrpc.com/connect` HTTP/h2c listener. Connect-Go natively
serves Connect, gRPC, and gRPC-Web on the same port, so browsers can
now reach the authenticated surface without giving up the gRPC
framing native and desktop clients may use later. The decorator
stack (envelope → session → payload-hash → signature →
freshness/replay → rate-limit → routing/push) is reused unchanged
behind a small Connect → gRPC adapter and a `grpc.ServerStream`
shim around `*connect.ServerStream`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-07 11:49:28 +02:00
parent 39b7b2ef29
commit 118f7c17a2
30 changed files with 1009 additions and 772 deletions
+279
View File
@@ -0,0 +1,279 @@
package testenv
import (
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"encoding/base64"
"errors"
"fmt"
"net"
"net/http"
"sync/atomic"
"time"
gatewayauthn "galaxy/gateway/authn"
gatewayv1 "galaxy/gateway/proto/galaxy/gateway/v1"
"galaxy/gateway/proto/galaxy/gateway/v1/gatewayv1connect"
"connectrpc.com/connect"
"github.com/google/uuid"
"golang.org/x/net/http2"
)
// SignedGatewayClient drives the authenticated edge surface of the
// gateway from tests. It signs ExecuteCommand envelopes with the
// session's Ed25519 private key, verifies response signatures with
// the gateway's response-signer public key, and exposes a
// SubscribeEvents helper. The client speaks Connect over HTTP/2
// cleartext (h2c) — the gateway listener supports that natively
// alongside gRPC and gRPC-Web on the same port.
type SignedGatewayClient struct {
httpClient *http.Client
edge gatewayv1connect.EdgeGatewayClient
deviceSID string
privateKey ed25519.PrivateKey
respPub ed25519.PublicKey
requestSeq uint64
}
// NewSession is the device-session shape returned by registration.
type NewSession struct {
DeviceSessionID string
PrivateKey ed25519.PrivateKey
PublicKey ed25519.PublicKey
}
// GenerateSessionKeyPair returns a fresh Ed25519 keypair for use in
// `confirm-email-code`.
func GenerateSessionKeyPair() (ed25519.PublicKey, ed25519.PrivateKey, error) {
return ed25519.GenerateKey(rand.Reader)
}
// EncodePublicKey base64-encodes the raw 32-byte Ed25519 public key
// for the `client_public_key` field.
func EncodePublicKey(pub ed25519.PublicKey) string {
return base64.StdEncoding.EncodeToString(pub)
}
// DialGateway opens a Connect (HTTP/2 cleartext) client against the
// gateway's authenticated edge listener at addr ("host:port") and
// prepares a signing client bound to deviceSID.
func DialGateway(_ context.Context, addr string, deviceSID string, privateKey ed25519.PrivateKey, respPub ed25519.PublicKey) (*SignedGatewayClient, error) {
if addr == "" {
return nil, fmt.Errorf("dial gateway: empty addr")
}
httpClient := &http.Client{
Transport: &http2.Transport{
AllowHTTP: true,
DialTLSContext: func(ctx context.Context, network, target string, _ *tls.Config) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, network, target)
},
},
}
edge := gatewayv1connect.NewEdgeGatewayClient(httpClient, "http://"+addr)
return &SignedGatewayClient{
httpClient: httpClient,
edge: edge,
deviceSID: deviceSID,
privateKey: privateKey,
respPub: respPub,
}, nil
}
// Close releases idle HTTP/2 connections held by the underlying transport.
// The Connect client itself is stateless, so this is best-effort.
func (c *SignedGatewayClient) Close() error {
if c.httpClient != nil {
if transport, ok := c.httpClient.Transport.(*http2.Transport); ok {
transport.CloseIdleConnections()
}
}
return nil
}
// ExecuteOptions tunes one ExecuteCommand call. The zero value
// produces a fresh `request_id` and the current timestamp; tests that
// need a fixed request_id (anti-replay) or a stale timestamp
// (freshness window) override the relevant fields.
type ExecuteOptions struct {
RequestID string
TimestampMS int64
OverrideSignature []byte
OverridePayloadHash []byte
OverrideSessionID string
OverrideProtocolVersion string
}
// ExecuteResult is the verified response of a successful
// ExecuteCommand. PayloadBytes is the authenticated FlatBuffers
// blob; tests decode it via galaxy/transcoder.
type ExecuteResult struct {
ResultCode string
PayloadBytes []byte
RequestID string
TimestampMS int64
}
// Execute signs the supplied payload, calls ExecuteCommand, verifies
// the response signature against the gateway response signer, and
// returns the decoded result.
func (c *SignedGatewayClient) Execute(ctx context.Context, messageType string, payload []byte, opts ExecuteOptions) (*ExecuteResult, error) {
if len(payload) == 0 {
return nil, errors.New("ExecuteCommand requires non-empty payload")
}
requestID := opts.RequestID
if requestID == "" {
requestID = uuid.NewString()
}
timestampMS := opts.TimestampMS
if timestampMS == 0 {
timestampMS = time.Now().UnixMilli()
}
protocolVersion := opts.OverrideProtocolVersion
if protocolVersion == "" {
protocolVersion = "v1"
}
deviceSID := opts.OverrideSessionID
if deviceSID == "" {
deviceSID = c.deviceSID
}
hash := opts.OverridePayloadHash
if hash == nil {
sum := sha256.Sum256(payload)
hash = sum[:]
}
signature := opts.OverrideSignature
if signature == nil {
input := gatewayauthn.BuildRequestSigningInput(gatewayauthn.RequestSigningFields{
ProtocolVersion: protocolVersion,
DeviceSessionID: deviceSID,
MessageType: messageType,
TimestampMS: timestampMS,
RequestID: requestID,
PayloadHash: hash,
})
signature = ed25519.Sign(c.privateKey, input)
}
req := &gatewayv1.ExecuteCommandRequest{
ProtocolVersion: protocolVersion,
DeviceSessionId: deviceSID,
MessageType: messageType,
TimestampMs: timestampMS,
RequestId: requestID,
PayloadBytes: payload,
PayloadHash: hash,
Signature: signature,
}
atomic.AddUint64(&c.requestSeq, 1)
respWrap, err := c.edge.ExecuteCommand(ctx, connect.NewRequest(req))
if err != nil {
return nil, err
}
resp := respWrap.Msg
respHash := sha256.Sum256(resp.GetPayloadBytes())
if string(respHash[:]) != string(resp.GetPayloadHash()) {
return nil, fmt.Errorf("response payload_hash mismatch")
}
if err := gatewayauthn.VerifyResponseSignature(c.respPub, resp.GetSignature(), gatewayauthn.ResponseSigningFields{
ProtocolVersion: resp.GetProtocolVersion(),
RequestID: resp.GetRequestId(),
TimestampMS: resp.GetTimestampMs(),
ResultCode: resp.GetResultCode(),
PayloadHash: resp.GetPayloadHash(),
}); err != nil {
return nil, fmt.Errorf("response signature verification failed: %w", err)
}
return &ExecuteResult{
ResultCode: resp.GetResultCode(),
PayloadBytes: resp.GetPayloadBytes(),
RequestID: resp.GetRequestId(),
TimestampMS: resp.GetTimestampMs(),
}, nil
}
// SubscribeEvents opens the authenticated server-streaming
// SubscribeEvents RPC. The returned channel receives every
// authenticated event the gateway delivers; the channel closes when
// the stream ends or when ctx is done. Errors land on the err
// channel.
func (c *SignedGatewayClient) SubscribeEvents(ctx context.Context, messageType string) (<-chan *gatewayv1.GatewayEvent, <-chan error, error) {
requestID := uuid.NewString()
timestampMS := time.Now().UnixMilli()
protocolVersion := "v1"
emptyHash := sha256.Sum256(nil)
signature := ed25519.Sign(c.privateKey, gatewayauthn.BuildRequestSigningInput(gatewayauthn.RequestSigningFields{
ProtocolVersion: protocolVersion,
DeviceSessionID: c.deviceSID,
MessageType: messageType,
TimestampMS: timestampMS,
RequestID: requestID,
PayloadHash: emptyHash[:],
}))
stream, err := c.edge.SubscribeEvents(ctx, connect.NewRequest(&gatewayv1.SubscribeEventsRequest{
ProtocolVersion: protocolVersion,
DeviceSessionId: c.deviceSID,
MessageType: messageType,
TimestampMs: timestampMS,
RequestId: requestID,
PayloadHash: emptyHash[:],
Signature: signature,
}))
if err != nil {
return nil, nil, fmt.Errorf("open subscribe events: %w", err)
}
events := make(chan *gatewayv1.GatewayEvent, 16)
errs := make(chan error, 1)
go func() {
defer close(events)
defer func() { _ = stream.Close() }()
for stream.Receive() {
events <- stream.Msg()
}
errs <- stream.Err()
}()
return events, errs, nil
}
// IsUnauthenticated reports whether err carries Connect's
// CodeUnauthenticated, useful for negative-path edge tests.
func IsUnauthenticated(err error) bool {
return connect.CodeOf(err) == connect.CodeUnauthenticated
}
// IsInvalidArgument reports whether err carries Connect's
// CodeInvalidArgument (used for malformed envelopes and unsupported
// protocol_version).
func IsInvalidArgument(err error) bool {
return connect.CodeOf(err) == connect.CodeInvalidArgument
}
// IsResourceExhausted reports whether err carries Connect's
// CodeResourceExhausted (used for replay rejection or rate-limit
// rejections).
func IsResourceExhausted(err error) bool {
return connect.CodeOf(err) == connect.CodeResourceExhausted
}
// IsFailedPrecondition reports whether err carries Connect's
// CodeFailedPrecondition. The gateway uses this code for replay
// rejections (the canonical envelope was authentic but the
// `request_id` was already consumed).
func IsFailedPrecondition(err error) bool {
return connect.CodeOf(err) == connect.CodeFailedPrecondition
}