diff --git a/gateway/clipboard.go b/gateway/clipboard.go index f929e4d..6db6d3d 100644 --- a/gateway/clipboard.go +++ b/gateway/clipboard.go @@ -46,10 +46,7 @@ func ValidateGatewayClipboard(policy protocol.ClipboardPolicy, value protocol.Ga if err := policy.Validate(); err != nil || value.Validate() != nil || !utf8.ValidString(value.Text) { return ErrProviderMalformed } - if len(value.Text) > int(policy.MaxTextBytes) || len(value.LoopToken) < 16 || len(value.LoopToken) > 128 { - return ErrProviderMalformed - } - if _, err := base64.RawURLEncoding.DecodeString(value.LoopToken); err != nil { + if len(value.Text) > int(policy.MaxTextBytes) { return ErrProviderMalformed } switch value.Direction { diff --git a/gateway/clipboard_test.go b/gateway/clipboard_test.go index 6f63b91..258d66f 100644 --- a/gateway/clipboard_test.go +++ b/gateway/clipboard_test.go @@ -15,7 +15,7 @@ func TestValidateGatewayClipboardEnforcesServerOwnedPolicy(t *testing.T) { if err := ValidateGatewayClipboard(policy, valid); err != nil { t.Fatalf("ValidateGatewayClipboard() valid text = %v", err) } - if err := ValidateGatewayClipboard(policy, protocol.GatewayClipboardText{Direction: "client_to_provider", Text: "hello", Encoding: "utf-8", LoopToken: "not base64url!"}); err == nil { + if err := ValidateGatewayClipboard(policy, protocol.GatewayClipboardText{Direction: "client_to_provider", Text: "hello", Encoding: "utf-8", LoopToken: "!!!!!!!!!!!!!!!!"}); err == nil { t.Fatal("ValidateGatewayClipboard() accepted malformed loop token") } if err := ValidateGatewayClipboard(policy, protocol.GatewayClipboardText{Direction: "client_to_provider", Text: strings.Repeat("x", 6), Encoding: "utf-8", LoopToken: "abcdefghijklmnop"}); err == nil { diff --git a/gateway/gateway_test.go b/gateway/gateway_test.go index b3c52ac..f222f52 100644 --- a/gateway/gateway_test.go +++ b/gateway/gateway_test.go @@ -8,6 +8,7 @@ import ( "crypto/tls" "crypto/x509" "crypto/x509/pkix" + "encoding/base64" "encoding/hex" "errors" "math/big" @@ -320,6 +321,197 @@ func TestAdmissionQUICMTLSRelayAndCleanup(t *testing.T) { } } +func TestRegisteredChannelFramesTraversePublicTransport(t *testing.T) { + h := newGatewayTransportHarness(t) + + input, err := EncodeInputEvent(InputEvent{Sequence: 7, Device: "keyboard", Code: 7, Pressed: true}) + if err != nil { + t.Fatal(err) + } + feedback, err := EncodeClientFeedback(Feedback{Sequence: 8, Kind: FeedbackIDR}) + if err != nil { + t.Fatal(err) + } + clipboard, err := protocol.EncodeGatewayClipboardText(protocol.GatewayClipboardText{Direction: "client_to_provider", Text: "registered clipboard", Encoding: "utf-8", LoopToken: "abcdefghijklmnop"}) + if err != nil { + t.Fatal(err) + } + for _, frame := range []protocol.ChannelFrame{ + testChannelFrame("input.sequenced.v1", 7, input), + testChannelFrame("control.ack.v1", 8, feedback), + testChannelFrame("clipboard.text.v1", 9, clipboard), + } { + encoded, encodeErr := protocol.EncodeChannelFrame(frame) + if encodeErr != nil { + t.Fatal(encodeErr) + } + if writeErr := h.client.writeControl(encoded); writeErr != nil { + t.Fatal(writeErr) + } + } + + select { + case value := <-h.session.clipboardWrites: + if value != "registered clipboard" { + t.Fatalf("provider clipboard = %q", value) + } + case <-time.After(time.Second): + t.Fatal("registered clipboard flow was not forwarded") + } + deadline := time.Now().Add(time.Second) + for { + h.session.mu.Lock() + inputs := append([]InputEvent(nil), h.session.inputs...) + feedbacks := append([]Feedback(nil), h.session.feedback...) + h.session.mu.Unlock() + if len(inputs) == 1 && inputs[0].Sequence == 7 && len(feedbacks) == 1 && feedbacks[0].Sequence == 8 && feedbacks[0].Kind == FeedbackIDR { + break + } + if time.Now().After(deadline) { + t.Fatalf("registered flows inputs=%#v feedback=%#v", inputs, feedbacks) + } + time.Sleep(time.Millisecond) + } + + h.session.EmitEvent(ProviderEvent{Kind: ProviderEventRumble, Payload: []byte{1, 2, 3, 4, 5}}) + eventCtx, cancelEvent := context.WithTimeout(context.Background(), time.Second) + event, err := h.client.ReceiveProviderEvent(eventCtx) + cancelEvent() + if err != nil || event.Kind != ProviderEventRumble || string(event.Payload) != string([]byte{1, 2, 3, 4, 5}) { + t.Fatalf("registered provider feedback = %#v, %v", event, err) + } + + h.session.mu.Lock() + h.session.clipboard = "registered provider clipboard" + h.session.mu.Unlock() + clipboardCtx, cancelClipboard := context.WithTimeout(context.Background(), time.Second) + value, err := h.client.ReceiveClipboard(clipboardCtx) + cancelClipboard() + if err != nil || value.Text != "registered provider clipboard" { + t.Fatalf("registered provider clipboard = %#v, %v", value, err) + } +} + +func TestPrivateChannelAliasesAreRejectedOnPublicTransport(t *testing.T) { + for _, test := range []struct { + alias string + payload func(t *testing.T) []byte + }{ + {"control", func(t *testing.T) []byte { + t.Helper() + value, err := EncodeClientFeedback(Feedback{Kind: FeedbackIDR}) + if err != nil { + t.Fatal(err) + } + return value + }}, + {"input", func(t *testing.T) []byte { + t.Helper() + value, err := EncodeInputEvent(InputEvent{Device: "keyboard", Code: 7, Pressed: true}) + if err != nil { + t.Fatal(err) + } + return value + }}, + {"clipboard", func(t *testing.T) []byte { + t.Helper() + value, err := protocol.EncodeGatewayClipboardText(protocol.GatewayClipboardText{Direction: "client_to_provider", Text: "alias", Encoding: "utf-8", LoopToken: "abcdefghijklmnop"}) + if err != nil { + t.Fatal(err) + } + return value + }}, + } { + t.Run(test.alias, func(t *testing.T) { + h := newGatewayTransportHarness(t) + encoded, err := protocol.EncodeChannelFrame(testChannelFrame(test.alias, 1, test.payload(t))) + if err != nil { + t.Fatal(err) + } + if err := h.client.writeControl(encoded); err != nil { + t.Fatal(err) + } + h.waitReleased(t) + h.session.mu.Lock() + defer h.session.mu.Unlock() + if len(h.session.inputs) != 0 || len(h.session.feedback) != 0 { + t.Fatalf("alias reached provider: inputs=%#v feedback=%#v", h.session.inputs, h.session.feedback) + } + }) + } +} + +func TestProviderClipboardAuditWaitsForPublicTransportDelivery(t *testing.T) { + h := newGatewayTransportHarness(t) + h.client.control.CancelRead(0) + h.session.mu.Lock() + h.session.clipboard = "undeliverable clipboard" + h.session.mu.Unlock() + + h.waitReleased(t) + for _, audit := range h.reporter.Audits() { + if audit.Direction == "provider_to_client" && audit.Outcome == "forwarded" { + t.Fatalf("failed delivery was audited as forwarded: %#v", audit) + } + } +} + +func testChannelFrame(flowID string, sequence int64, payload []byte) protocol.ChannelFrame { + return protocol.ChannelFrame{Version: "1", FlowID: flowID, Sequence: sequence, Flags: 0, FragmentIndex: 0, FragmentCount: 1, TimestampMs: time.Now().UnixMilli(), Payload: base64.StdEncoding.EncodeToString(payload)} +} + +type gatewayTransportHarness struct { + client *Client + session *fakeSession + admission *oneTimeAdmission + reporter *recordingProviderStateReporter +} + +func newGatewayTransportHarness(t *testing.T) gatewayTransportHarness { + t.Helper() + serverTLS, clientTLS := testTLS(t) + fake := NewFakeApollo(FakeApolloConfig{Now: time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC)}) + authority := protocol.SessionAuthority{Version: "1", SessionID: "session-transport", GatewayID: "gateway-1", Audience: "versevdi-gateway", ReconnectSequence: 0, ExpiresAt: time.Now().Add(5 * time.Second).UTC().Format(time.RFC3339Nano), Capabilities: DefaultCapabilities(), ProviderProfile: ProviderProfileApollo, ProviderIdentity: fake.config.Identity.Key()} + admission := &oneTimeAdmission{authority: authority, released: make(chan struct{})} + reporter := &recordingProviderStateReporter{} + server, err := NewServer(ServerConfig{ListenAddress: "127.0.0.1:0", TLSConfig: serverTLS, GatewayID: "gateway-1", Capabilities: DefaultCapabilities(), ProviderCapabilities: DefaultCapabilities(), Admission: admission, ProviderStateReporter: reporter, ClipboardAuditReporter: reporter, Provider: fake}) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + serveDone := make(chan error, 1) + go func() { serveDone <- server.Serve(ctx) }() + request := protocol.TunnelAdmissionRequest{Version: "1", SessionID: authority.SessionID, GatewayID: authority.GatewayID, Audience: authority.Audience, Grant: strings.Repeat("g", 64), ReconnectSequence: 0, ClientNonce: "nonce-0000000001", DeviceSignature: strings.Repeat("s", 86), Capabilities: DefaultCapabilities()} + client, err := Dial(context.Background(), server.Addr().String(), clientTLS, request) + if err != nil { + cancel() + _ = server.Close() + t.Fatal(err) + } + session, ok := fake.LastSession().(*fakeSession) + if !ok { + t.Fatal("fake provider session type") + } + t.Cleanup(func() { + _ = client.Close() + cancel() + _ = server.Close() + if err := <-serveDone; err != nil { + t.Errorf("serve: %v", err) + } + }) + return gatewayTransportHarness{client: client, session: session, admission: admission, reporter: reporter} +} + +func (h gatewayTransportHarness) waitReleased(t *testing.T) { + t.Helper() + select { + case <-h.admission.released: + case <-time.After(2 * time.Second): + t.Fatal("gateway did not reject the channel") + } +} + func testTLS(t *testing.T) (*tls.Config, *tls.Config) { t.Helper() caKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) diff --git a/gateway/transport.go b/gateway/transport.go index a0e5580..975cbf2 100644 --- a/gateway/transport.go +++ b/gateway/transport.go @@ -23,6 +23,9 @@ const ( defaultControlLimit = 128 * 1024 clientControlBacklog = 64 applicationError = quic.ApplicationErrorCode(0x100) + controlFlowID = "control.ack.v1" + inputFlowID = "input.sequenced.v1" + clipboardFlowID = "clipboard.text.v1" ) var ( @@ -446,11 +449,11 @@ func (s *gatewaySession) clipboardLoop() { } continue } - if err := s.reportClipboardAudit(value.Direction, "forwarded", clipboardAuditTextBytes(value.Text), "forwarded"); err != nil { + if err := s.sendClipboard(value); err != nil { s.result <- err return } - if err := s.sendClipboard(value); err != nil { + if err := s.reportClipboardAudit(value.Direction, "forwarded", clipboardAuditTextBytes(value.Text), "forwarded"); err != nil { s.result <- err return } @@ -499,7 +502,7 @@ func (s *gatewaySession) controlLoop() { return } switch frame.FlowID { - case "control": + case controlFlowID: sequence, sequenceErr := channelSequence(frame.Sequence) if sequenceErr != nil { s.result <- sequenceErr @@ -509,7 +512,7 @@ func (s *gatewaySession) controlLoop() { s.result <- err return } - case "input": + case inputFlowID: sequence, sequenceErr := channelSequence(frame.Sequence) if sequenceErr != nil { s.result <- sequenceErr @@ -519,7 +522,7 @@ func (s *gatewaySession) controlLoop() { s.result <- err return } - case "clipboard": + case clipboardFlowID: value, decodeErr := protocol.DecodeGatewayClipboardText(payload) if decodeErr != nil { s.result <- ErrProviderMalformed @@ -625,7 +628,7 @@ func (s *gatewaySession) sendControl(sequence uint32, payload []byte) error { if len(payload) > 1024 { return ErrFramePayloadLimit } - frame := protocol.ChannelFrame{Version: "1", FlowID: "control", Sequence: int64(sequence), Flags: 0, FragmentIndex: 0, FragmentCount: 1, TimestampMs: time.Now().UnixMilli(), Payload: base64.StdEncoding.EncodeToString(payload)} + frame := protocol.ChannelFrame{Version: "1", FlowID: controlFlowID, Sequence: int64(sequence), Flags: 0, FragmentIndex: 0, FragmentCount: 1, TimestampMs: time.Now().UnixMilli(), Payload: base64.StdEncoding.EncodeToString(payload)} encoded, err := protocol.EncodeChannelFrame(frame) if err != nil { return err @@ -640,7 +643,7 @@ func (s *gatewaySession) sendClipboard(value protocol.GatewayClipboardText) erro if err != nil { return err } - frame := protocol.ChannelFrame{Version: "1", FlowID: "clipboard", Sequence: int64(s.sequence.Add(1)), Flags: 0, FragmentIndex: 0, FragmentCount: 1, TimestampMs: time.Now().UnixMilli(), Payload: base64.StdEncoding.EncodeToString(payload)} + frame := protocol.ChannelFrame{Version: "1", FlowID: clipboardFlowID, Sequence: int64(s.sequence.Add(1)), Flags: 0, FragmentIndex: 0, FragmentCount: 1, TimestampMs: time.Now().UnixMilli(), Payload: base64.StdEncoding.EncodeToString(payload)} encoded, err := protocol.EncodeChannelFrame(frame) if err != nil { return err @@ -899,7 +902,7 @@ func (c *Client) SendInput(event InputEvent) error { if err != nil { return err } - frame := protocol.ChannelFrame{Version: "1", FlowID: "input", Sequence: int64(event.Sequence), Flags: 0, FragmentIndex: 0, FragmentCount: 1, TimestampMs: time.Now().UnixMilli(), Payload: base64.StdEncoding.EncodeToString(payload)} + frame := protocol.ChannelFrame{Version: "1", FlowID: inputFlowID, Sequence: int64(event.Sequence), Flags: 0, FragmentIndex: 0, FragmentCount: 1, TimestampMs: time.Now().UnixMilli(), Payload: base64.StdEncoding.EncodeToString(payload)} encoded, err := protocol.EncodeChannelFrame(frame) if err != nil { return err @@ -924,7 +927,7 @@ func (c *Client) SendClipboard(value protocol.GatewayClipboardText) error { if err != nil { return err } - frame := protocol.ChannelFrame{Version: "1", FlowID: "clipboard", Sequence: 0, Flags: 0, FragmentIndex: 0, FragmentCount: 1, TimestampMs: time.Now().UnixMilli(), Payload: base64.StdEncoding.EncodeToString(payload)} + frame := protocol.ChannelFrame{Version: "1", FlowID: clipboardFlowID, Sequence: 0, Flags: 0, FragmentIndex: 0, FragmentCount: 1, TimestampMs: time.Now().UnixMilli(), Payload: base64.StdEncoding.EncodeToString(payload)} encoded, err := protocol.EncodeChannelFrame(frame) if err != nil { return err @@ -933,7 +936,7 @@ func (c *Client) SendClipboard(value protocol.GatewayClipboardText) error { } func (c *Client) sendControl(sequence uint32, payload []byte) error { - frame := protocol.ChannelFrame{Version: "1", FlowID: "control", Sequence: int64(sequence), Flags: 0, FragmentIndex: 0, FragmentCount: 1, TimestampMs: time.Now().UnixMilli(), Payload: base64.StdEncoding.EncodeToString(payload)} + frame := protocol.ChannelFrame{Version: "1", FlowID: controlFlowID, Sequence: int64(sequence), Flags: 0, FragmentIndex: 0, FragmentCount: 1, TimestampMs: time.Now().UnixMilli(), Payload: base64.StdEncoding.EncodeToString(payload)} encoded, err := protocol.EncodeChannelFrame(frame) if err != nil { return err @@ -956,7 +959,7 @@ func (c *Client) ReceiveFrame(ctx context.Context) (Frame, error) { } func (c *Client) ReceiveProviderEvent(ctx context.Context) (ProviderEvent, error) { - payload, err := c.receiveControlPayload(ctx, "control") + payload, err := c.receiveControlPayload(ctx, controlFlowID) if err != nil { return ProviderEvent{}, err } @@ -967,7 +970,7 @@ func (c *Client) ReceiveProviderEvent(ctx context.Context) (ProviderEvent, error } func (c *Client) ReceiveClipboard(ctx context.Context) (protocol.GatewayClipboardText, error) { - payload, err := c.receiveControlPayload(ctx, "clipboard") + payload, err := c.receiveControlPayload(ctx, clipboardFlowID) if err != nil { return protocol.GatewayClipboardText{}, err } @@ -975,7 +978,7 @@ func (c *Client) ReceiveClipboard(ctx context.Context) (protocol.GatewayClipboar } func (c *Client) receiveControlPayload(ctx context.Context, flowID string) ([]byte, error) { - if c == nil || c.control == nil || (flowID != "control" && flowID != "clipboard") { + if c == nil || c.control == nil || (flowID != controlFlowID && flowID != clipboardFlowID) { return nil, ErrProviderMalformed } c.controlReadMu.Lock() @@ -1000,7 +1003,7 @@ func (c *Client) receiveControlPayload(ctx context.Context, flowID string) ([]by return nil, err } frame, err := protocol.DecodeChannelFrame(data) - if err != nil || (frame.FlowID != "control" && frame.FlowID != "clipboard") { + if err != nil || (frame.FlowID != controlFlowID && frame.FlowID != clipboardFlowID) { return nil, ErrProviderMalformed } payload, err := base64.StdEncoding.DecodeString(frame.Payload)