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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user