Compare commits

..
Author SHA1 Message Date
sechmachine ebfe07376d fix(protocol): harden gateway contract validation
Verify Protocol / verify (push) Canceled after 0s
Verify Protocol / module (push) Successful in 2m12s
2026-07-29 21:16:53 +07:00
sechmachine 0ea21cd3f2 feat(protocol): define gateway control envelopes 2026-07-29 17:24:18 +07:00
34 changed files with 1759 additions and 62 deletions
+2
View File
@@ -20,6 +20,7 @@ source-verify:
$(PYTHON) -B tools/fixture_digest.py
scope-verify:
$(PYTHON) -B tools/test_check_scope.py
$(PYTHON) -B tools/check_scope.py
go-test:
@@ -39,6 +40,7 @@ conformance:
frame-verify:
$(PYTHON) tools/validate_frames.py
$(PYTHON) tools/validate_gateway_envelopes.py
clean-generated:
$(PYTHON) tools/generate.py --check
@@ -0,0 +1,6 @@
id version kind input expected
valid-forwarded 1 gateway_clipboard_audit direction=client_to_provider;outcome=forwarded;text_bytes=1024;reason=forwarded valid
valid-suppressed 1 gateway_clipboard_audit direction=provider_to_client;outcome=suppressed;text_bytes=12;reason=loop valid
audit-invalid-direction 1 gateway_clipboard_audit direction=bidirectional;outcome=forwarded;text_bytes=1;reason=forwarded invalid:clipboard_audit
invalid-bytes 1 gateway_clipboard_audit direction=client_to_provider;outcome=rejected;text_bytes=65537;reason=policy invalid:clipboard_audit
invalid-content 1 gateway_clipboard_audit direction=client_to_provider;outcome=rejected;text_bytes=1;reason=rate;text=forbidden invalid:forbidden
1 id version kind input expected
2 valid-forwarded 1 gateway_clipboard_audit direction=client_to_provider;outcome=forwarded;text_bytes=1024;reason=forwarded valid
3 valid-suppressed 1 gateway_clipboard_audit direction=provider_to_client;outcome=suppressed;text_bytes=12;reason=loop valid
4 audit-invalid-direction 1 gateway_clipboard_audit direction=bidirectional;outcome=forwarded;text_bytes=1;reason=forwarded invalid:clipboard_audit
5 invalid-bytes 1 gateway_clipboard_audit direction=client_to_provider;outcome=rejected;text_bytes=65537;reason=policy invalid:clipboard_audit
6 invalid-content 1 gateway_clipboard_audit direction=client_to_provider;outcome=rejected;text_bytes=1;reason=rate;text=forbidden invalid:forbidden
@@ -0,0 +1,12 @@
id version kind input expected
valid-client-to-provider 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=abcdefghijklmnop valid
valid-provider-to-client 1 gateway_clipboard direction=provider_to_client;text=host%20text;encoding=utf-8;loop_token=qrstuvwxyzABCDEF valid
valid-token-alphabet 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_ valid
valid-token-max 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_ valid
invalid-direction 1 gateway_clipboard direction=bidirectional;text=hello;encoding=utf-8;loop_token=abcdefghijklmnop invalid:clipboard
invalid-token 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=short invalid:clipboard
invalid-token-15 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=abcdefghijklmno invalid:clipboard
invalid-token-129 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_A invalid:clipboard
invalid-token-character 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=!!!!!!!!!!!!!!!! invalid:clipboard
invalid-token-trailing-bits 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=AAAAAAAAAAAAAAAAAB invalid:clipboard
invalid-file 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=abcdefghijklmnop;file=file.txt invalid:forbidden
1 id version kind input expected
2 valid-client-to-provider 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=abcdefghijklmnop valid
3 valid-provider-to-client 1 gateway_clipboard direction=provider_to_client;text=host%20text;encoding=utf-8;loop_token=qrstuvwxyzABCDEF valid
4 valid-token-alphabet 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_ valid
5 valid-token-max 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_ valid
6 invalid-direction 1 gateway_clipboard direction=bidirectional;text=hello;encoding=utf-8;loop_token=abcdefghijklmnop invalid:clipboard
7 invalid-token 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=short invalid:clipboard
8 invalid-token-15 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=abcdefghijklmno invalid:clipboard
9 invalid-token-129 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_A invalid:clipboard
10 invalid-token-character 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=!!!!!!!!!!!!!!!! invalid:clipboard
11 invalid-token-trailing-bits 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=AAAAAAAAAAAAAAAAAB invalid:clipboard
12 invalid-file 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=abcdefghijklmnop;file=file.txt invalid:forbidden
@@ -0,0 +1,22 @@
id version kind input expected
valid-keyboard-press 1 gateway_input hex=5647493101040102001e valid
valid-keyboard-release 1 gateway_input hex=5647493101040000001e valid
valid-mouse-button 1 gateway_input hex=564749310203010100 valid
valid-mouse-release 1 gateway_input hex=564749310203000100 valid
valid-relative-mouse 1 gateway_input hex=564749310304fffe0003 valid
valid-utf8-scalar 1 gateway_input hex=564749310403e29883 valid
valid-controller 1 gateway_input hex=5647493105110200030004ffff00010002000300040005 valid
valid-controller-release 1 gateway_input hex=5647493105110200000000000000000000000000000000 valid
valid-idr 1 gateway_feedback hex=5647463100010000 valid
valid-fec 1 gateway_feedback hex=56474631000200150000002a000500030002000a000200080002140001 valid
valid-termination 1 gateway_feedback hex=564746310110000400000001 valid
valid-rumble 1 gateway_feedback hex=56474631011100050112345678 valid
valid-hdr 1 gateway_feedback hex=564746310112000101 valid
invalid-input-magic 1 gateway_input hex=494e503101040102001e invalid:magic
invalid-input-kind 1 gateway_input hex=564749317f00 invalid:kind
invalid-input-reserved 1 gateway_input hex=564749310203010101 invalid:reserved
invalid-input-utf8 1 gateway_input hex=564749310402c328 invalid:utf8
invalid-input-length 1 gateway_input hex=564749310104010200 invalid:length
invalid-feedback-direction 1 gateway_feedback hex=5647463101020000 invalid:direction
invalid-feedback-type 1 gateway_feedback hex=5647463100030000 invalid:type
invalid-feedback-length 1 gateway_feedback hex=5647463101100003000000 invalid:length
1 id version kind input expected
2 valid-keyboard-press 1 gateway_input hex=5647493101040102001e valid
3 valid-keyboard-release 1 gateway_input hex=5647493101040000001e valid
4 valid-mouse-button 1 gateway_input hex=564749310203010100 valid
5 valid-mouse-release 1 gateway_input hex=564749310203000100 valid
6 valid-relative-mouse 1 gateway_input hex=564749310304fffe0003 valid
7 valid-utf8-scalar 1 gateway_input hex=564749310403e29883 valid
8 valid-controller 1 gateway_input hex=5647493105110200030004ffff00010002000300040005 valid
9 valid-controller-release 1 gateway_input hex=5647493105110200000000000000000000000000000000 valid
10 valid-idr 1 gateway_feedback hex=5647463100010000 valid
11 valid-fec 1 gateway_feedback hex=56474631000200150000002a000500030002000a000200080002140001 valid
12 valid-termination 1 gateway_feedback hex=564746310110000400000001 valid
13 valid-rumble 1 gateway_feedback hex=56474631011100050112345678 valid
14 valid-hdr 1 gateway_feedback hex=564746310112000101 valid
15 invalid-input-magic 1 gateway_input hex=494e503101040102001e invalid:magic
16 invalid-input-kind 1 gateway_input hex=564749317f00 invalid:kind
17 invalid-input-reserved 1 gateway_input hex=564749310203010101 invalid:reserved
18 invalid-input-utf8 1 gateway_input hex=564749310402c328 invalid:utf8
19 invalid-input-length 1 gateway_input hex=564749310104010200 invalid:length
20 invalid-feedback-direction 1 gateway_feedback hex=5647463101020000 invalid:direction
21 invalid-feedback-type 1 gateway_feedback hex=5647463100030000 invalid:type
22 invalid-feedback-length 1 gateway_feedback hex=5647463101100003000000 invalid:length
+4 -1
View File
@@ -4,7 +4,10 @@
"fixtures/conformance/control-v1.tsv",
"fixtures/conformance/datagram-v1.tsv",
"fixtures/conformance/events-v1.tsv",
"fixtures/conformance/gateway-clipboard-audit-v1.tsv",
"fixtures/conformance/gateway-clipboard-v1.tsv",
"fixtures/conformance/gateway-input-feedback-v1.tsv",
"fixtures/conformance/tunnel-v1.tsv"
],
"corpus_sha256": "0874d39dd14c0107e602ea8909f8d53673f964c67dc9fd5fcadb8641e6ebf592"
"corpus_sha256": "69d5b12a533ff0d9786784b99aecc8a74a7ec2c6855b75c52e46ecff5bd3e6c5"
}
+8 -1
View File
@@ -29,4 +29,11 @@ Registered channels are `control.ack.v1`, `control.cancel.v1`, `clipboard.text.v
application flow IDs 10, 11, and 12 and a path-MTU-safe payload limit of 1,179 bytes;
larger encoded units use at most 16 validated fragments. Clipboard payloads are UTF-8
JSON text contracts and remain subject to the 65,536-byte text limit and explicit
authorization.
Server-owned direction, rate, and loop-token policy as defined in
`gateway-clipboard-v1.md`.
Within an active Phase 3C gateway session, `input.sequenced.v1` and the
bidirectional reliable `control.ack.v1` payloads additionally use the exact
provider-neutral grammars in [gateway-input-feedback-v1.md](gateway-input-feedback-v1.md).
Those grammars do not alter this datagram header or make provider traffic visible to
the Verse client.
+28
View File
@@ -0,0 +1,28 @@
# Gateway clipboard text v1
`clipboard.text.v1` is a reliable, authenticated gateway-only `ChannelFrame`
flow. Its UTF-8 JSON payload is a `GatewayClipboardText` object:
```json
{"direction":"client_to_provider","text":"text","encoding":"utf-8","loop_token":"base64url-token"}
```
`direction` is exact: the client may send only `client_to_provider`, and the
gateway may send only `provider_to_client`. The text contains no file name,
URL, binary value, or client-folder field and is at most the Server-owned
`clipboard_policy.max_text_bytes` value. `loop_token` is a 16--128 character
canonical unpadded ASCII base64url token generated by the originating endpoint. An endpoint MUST retain
recent token/value pairs only for the bounded policy window and MUST suppress a
matching reflected value; a mismatched, malformed, expired, or replayed token
is rejected without clipboard mutation.
The gateway receives the policy only in authenticated session work. A disabled
direction, a rate above `max_updates_per_minute`, invalid UTF-8, an oversized
payload, or an unknown field fails closed. Clipboard bytes are never emitted to
provider-state, audit, telemetry, or error payloads.
For every successfully delivered, loop-suppressed, or policy/rate/provider/malformed
rejection, the gateway sends an mTLS control-plane `GatewayClipboardAudit` record. It contains
only the session identifier, direction, bounded text-byte count, outcome, and a
fixed reason code; it contains neither text nor loop token. The Server persists it
against the broker session using the authenticated gateway identity.
+78
View File
@@ -0,0 +1,78 @@
# Gateway input and feedback v1
This grammar is carried only in an authenticated Phase 3C gateway session. It
is deliberately provider-neutral: it never carries provider routes,
certificates, credentials, opaque provider packets, clipboard bytes, files, or
client-folder data. It does not change the v1 datagram header or any existing
release candidate.
## `input.sequenced.v1` payload (`VGI1`)
All multibyte fields are unsigned big-endian. The payload has exactly six bytes
of header followed by the declared body:
| Offset | Size | Field | Rule |
|---:|---:|---|---|
| 0 | 4 | magic | ASCII `VGI1` |
| 4 | 1 | kind | one of the kinds below |
| 5 | 1 | payload length | exact body byte count |
| 6 | N | body | exact kind-specific body |
The decoder rejects an unknown kind, non-exact length, nonzero reserved byte,
unsupported controller index, malformed UTF-8, a non-scalar UTF-8 value, or a
payload larger than the channel limit before provider translation. A false
keyboard or mouse state and a zeroed controller state are explicit releases;
they are retained by the gateway and replayed as individual provider releases
during cleanup.
| Kind | Name | Exact body |
|---:|---|---|
| `0x01` | keyboard | `state` (`0` release, `1` press), `modifiers` (one byte), nonzero `scancode` (u16). |
| `0x02` | mouse button | `state` (`0` release, `1` press), `button` (`1` through `5`), reserved `0`. |
| `0x03` | relative mouse | `delta_x` (i16), `delta_y` (i16). |
| `0x04` | UTF-8 scalar | exactly one valid UTF-8 Unicode scalar, one through four bytes. |
| `0x05` | controller state | `controller` (0 through 15), `active_mask` (u16), `button_flags` (u16), `left_trigger` (u8), `right_trigger` (u8), `left_x` (i16), `left_y` (i16), `right_x` (i16), `right_y` (i16), `extra_button_flags` (u16). A zero `active_mask` and zero state is release. |
Keyboard, mouse button, UTF-8, and controller messages are delivered over the
gateway's reliable ordered input flow. Relative mouse is a state change, not a
pressed-state entry. The gateway maps the validated values to the provider's
separate keyboard, mouse, UTF-8, and controller control messages; it does not
forward this envelope to the provider.
## Reliable control payload (`VGF1`)
`control.ack.v1` remains the existing authenticated bidirectional reliable
control flow. Within an active gateway session, its provider-feedback payload
is the following exact envelope:
| Offset | Size | Field | Rule |
|---:|---:|---|---|
| 0 | 4 | magic | ASCII `VGF1` |
| 4 | 1 | direction | `0` client-to-gateway; `1` gateway-to-client |
| 5 | 1 | type | valid only for the stated direction |
| 6 | 2 | payload length | exact payload byte count |
| 8 | N | payload | exact type-specific body |
The client-to-gateway types are `0x01` IDR request (empty) and `0x02` FEC
status: `frame_index` (u32), `highest_received_sequence` (u16),
`next_contiguous_sequence` (u16), `missing_before_highest` (u16),
`total_data_packets` (u16), `total_parity_packets` (u16),
`received_data_packets` (u16), `received_parity_packets` (u16),
`fec_percentage` (u8), `multi_fec_block_index` (u8), and
`multi_fec_block_count` (u8). The gateway maps this fixed 21-byte structure to
the provider's unsequenced ENet FEC delivery; it does not put it on the reliable
provider input path.
The gateway-to-client types are `0x10` host termination (`exit_code` u32),
`0x11` rumble (`controller` u8, `low_frequency` u16,
`high_frequency` u16), and `0x12` HDR mode (`enabled` exactly `0` or `1`). The
gateway derives these from authenticated provider control messages, normalizes
their bounded fields, and rejects all unrecognized provider feedback. The HDR
envelope intentionally carries only the negotiated mode; provider-specific HDR
metadata remains behind the gateway boundary.
Apollo's pinned `src/stream.cpp` source defines separate termination, rumble,
and HDR control structures, while Moonlight common-C's `ControlStream.c` and
`InputStream.c` separate reliable input/control from UDP media. This Verse
grammar is a new normalized contract; it does not copy either implementation or
expose its wire format.
+2 -2
View File
@@ -4,12 +4,12 @@
"header_bytes": 21,
"maximum_frame_bytes": 65536,
"channels": [
{"id": 1, "name": "control.ack.v1", "direction": "bidirectional", "max_payload_bytes": 1024},
{"id": 1, "name": "control.ack.v1", "direction": "bidirectional", "max_payload_bytes": 1024, "payload_profile": "gateway-feedback-v1"},
{"id": 2, "name": "control.cancel.v1", "direction": "client-to-server", "max_payload_bytes": 2048},
{"id": 3, "name": "clipboard.text.v1", "direction": "bidirectional", "max_payload_bytes": 65515},
{"id": 10, "name": "media.video.v1", "direction": "server-to-client", "max_payload_bytes": 1179},
{"id": 11, "name": "media.audio.v1", "direction": "server-to-client", "max_payload_bytes": 1179},
{"id": 12, "name": "input.sequenced.v1", "direction": "client-to-server", "max_payload_bytes": 1179}
{"id": 12, "name": "input.sequenced.v1", "direction": "client-to-server", "max_payload_bytes": 1179, "payload_profile": "gateway-input-v1"}
],
"reserved_rejected": ["provider", "vm", "file-transfer", "clipboard.binary"]
}
+302 -4
View File
@@ -3,6 +3,7 @@ package protocol
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -12,7 +13,7 @@ import (
"time"
)
const SchemaSHA256 = "792abfb9cfe70e79911d499d76c009ab848713278bc240b88576df520580e480"
const SchemaSHA256 = "e98c75ef81bbeac6be2b8f11202c1ffecec0aa515b48576a26756290e99d5dd8"
const ProtocolVersion = "1.0.0"
const CurrentWireVersion = "1"
const NMinus1WireVersion = "0"
@@ -86,6 +87,13 @@ type ChannelFrame struct {
Payload string `json:"payload"`
}
type ClipboardPolicy struct {
ClientToProviderEnabled bool `json:"client_to_provider_enabled"`
ProviderToClientEnabled bool `json:"provider_to_client_enabled"`
MaxTextBytes int64 `json:"max_text_bytes"`
MaxUpdatesPerMinute int64 `json:"max_updates_per_minute"`
}
type ClipboardText struct {
Text string `json:"text"`
Encoding string `json:"encoding"`
@@ -158,6 +166,22 @@ type EventResume struct {
LastSequence int64 `json:"last_sequence"`
}
type GatewayClipboardAudit struct {
Version string `json:"version"`
SessionID string `json:"session_id"`
Direction string `json:"direction"`
Outcome string `json:"outcome"`
TextBytes int64 `json:"text_bytes"`
Reason string `json:"reason"`
}
type GatewayClipboardText struct {
Direction string `json:"direction"`
Text string `json:"text"`
Encoding string `json:"encoding"`
LoopToken string `json:"loop_token"`
}
type GatewayDrain struct {
Version string `json:"version"`
GatewayID string `json:"gateway_id"`
@@ -258,6 +282,8 @@ type ProviderSessionWork struct {
ClientCertificatePem string `json:"client_certificate_pem"`
ClientPrivateKeyPem string `json:"client_private_key_pem"`
ServerCertificatePem string `json:"server_certificate_pem"`
ClipboardPolicy ClipboardPolicy `json:"clipboard_policy"`
ProviderApplicationTerminationAllowed bool `json:"provider_application_termination_allowed"`
}
type ProviderState struct {
@@ -943,6 +969,9 @@ func (v ChannelFrame) Validate() error {
if len(v.Payload) > 87384 {
violations = append(violations, FieldViolation{Field: "payload", Code: "max_length"})
}
if len(v.Payload) > 65536 {
violations = append(violations, FieldViolation{Field: "payload", Code: "max_bytes"})
}
if v.FragmentIndex >= v.FragmentCount {
violations = append(violations, FieldViolation{Field: "fragment_index", Code: "invalid_order"})
}
@@ -985,9 +1014,6 @@ func DecodeChannelFrame(data []byte) (ChannelFrame, error) {
if raw, ok := fields["version"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return value, ValidationError{Violations: []FieldViolation{{Field: "version", Code: "required"}}}
}
if raw, ok := fields["payload"]; ok && len(raw) > 65536 {
return value, ValidationError{Violations: []FieldViolation{{Field: "payload", Code: "max_bytes"}}}
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&value); err != nil {
@@ -1013,6 +1039,78 @@ func EncodeChannelFrame(value ChannelFrame) ([]byte, error) {
return json.Marshal(value)
}
func (v ClipboardPolicy) Validate() error {
var violations []FieldViolation
if v.MaxTextBytes == 0 {
violations = append(violations, FieldViolation{Field: "max_text_bytes", Code: "required"})
}
if v.MaxTextBytes != 0 && v.MaxTextBytes < 1 {
violations = append(violations, FieldViolation{Field: "max_text_bytes", Code: "minimum"})
}
if v.MaxTextBytes > 65536 {
violations = append(violations, FieldViolation{Field: "max_text_bytes", Code: "maximum"})
}
if v.MaxUpdatesPerMinute == 0 {
violations = append(violations, FieldViolation{Field: "max_updates_per_minute", Code: "required"})
}
if v.MaxUpdatesPerMinute != 0 && v.MaxUpdatesPerMinute < 1 {
violations = append(violations, FieldViolation{Field: "max_updates_per_minute", Code: "minimum"})
}
if v.MaxUpdatesPerMinute > 120 {
violations = append(violations, FieldViolation{Field: "max_updates_per_minute", Code: "maximum"})
}
if len(violations) > 0 {
return ValidationError{Violations: violations}
}
return nil
}
func DecodeClipboardPolicy(data []byte) (ClipboardPolicy, error) {
var value ClipboardPolicy
if len(data) > 1024*1024 {
return value, errors.New("protocol payload exceeds limit")
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(data, &fields); err != nil {
return value, err
}
if raw, ok := fields["client_to_provider_enabled"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return value, ValidationError{Violations: []FieldViolation{{Field: "client_to_provider_enabled", Code: "required"}}}
}
if raw, ok := fields["max_text_bytes"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return value, ValidationError{Violations: []FieldViolation{{Field: "max_text_bytes", Code: "required"}}}
}
if raw, ok := fields["max_updates_per_minute"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return value, ValidationError{Violations: []FieldViolation{{Field: "max_updates_per_minute", Code: "required"}}}
}
if raw, ok := fields["provider_to_client_enabled"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return value, ValidationError{Violations: []FieldViolation{{Field: "provider_to_client_enabled", Code: "required"}}}
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&value); err != nil {
return value, err
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
if err == nil {
return value, errors.New("trailing JSON value")
}
return value, err
}
if err := value.Validate(); err != nil {
return value, err
}
return value, nil
}
func EncodeClipboardPolicy(value ClipboardPolicy) ([]byte, error) {
if err := value.Validate(); err != nil {
return nil, err
}
return json.Marshal(value)
}
func (v ClipboardText) Validate() error {
var violations []FieldViolation
if v.Text == "" {
@@ -1934,6 +2032,194 @@ func EncodeFieldViolation(value FieldViolation) ([]byte, error) {
return json.Marshal(value)
}
func (v GatewayClipboardAudit) Validate() error {
var violations []FieldViolation
if v.Version == "" {
violations = append(violations, FieldViolation{Field: "version", Code: "required"})
}
if v.Version != "1" && v.Version != "" {
violations = append(violations, FieldViolation{Field: "version", Code: "invalid_value"})
}
if v.SessionID == "" {
violations = append(violations, FieldViolation{Field: "session_id", Code: "required"})
}
if len(v.SessionID) < 1 && v.SessionID != "" {
violations = append(violations, FieldViolation{Field: "session_id", Code: "min_length"})
}
if len(v.SessionID) > 128 {
violations = append(violations, FieldViolation{Field: "session_id", Code: "max_length"})
}
if v.Direction == "" {
violations = append(violations, FieldViolation{Field: "direction", Code: "required"})
}
if v.Direction != "" && !(v.Direction == "client_to_provider" || v.Direction == "provider_to_client") {
violations = append(violations, FieldViolation{Field: "direction", Code: "invalid_value"})
}
if v.Outcome == "" {
violations = append(violations, FieldViolation{Field: "outcome", Code: "required"})
}
if v.Outcome != "" && !(v.Outcome == "forwarded" || v.Outcome == "suppressed" || v.Outcome == "rejected") {
violations = append(violations, FieldViolation{Field: "outcome", Code: "invalid_value"})
}
if v.TextBytes != 0 && v.TextBytes < 0 {
violations = append(violations, FieldViolation{Field: "text_bytes", Code: "minimum"})
}
if v.TextBytes > 65536 {
violations = append(violations, FieldViolation{Field: "text_bytes", Code: "maximum"})
}
if v.Reason == "" {
violations = append(violations, FieldViolation{Field: "reason", Code: "required"})
}
if v.Reason != "" && !(v.Reason == "forwarded" || v.Reason == "loop" || v.Reason == "policy" || v.Reason == "rate" || v.Reason == "provider" || v.Reason == "malformed") {
violations = append(violations, FieldViolation{Field: "reason", Code: "invalid_value"})
}
if len(violations) > 0 {
return ValidationError{Violations: violations}
}
return nil
}
func DecodeGatewayClipboardAudit(data []byte) (GatewayClipboardAudit, error) {
var value GatewayClipboardAudit
if len(data) > 1024*1024 {
return value, errors.New("protocol payload exceeds limit")
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(data, &fields); err != nil {
return value, err
}
if raw, ok := fields["direction"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return value, ValidationError{Violations: []FieldViolation{{Field: "direction", Code: "required"}}}
}
if raw, ok := fields["outcome"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return value, ValidationError{Violations: []FieldViolation{{Field: "outcome", Code: "required"}}}
}
if raw, ok := fields["reason"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return value, ValidationError{Violations: []FieldViolation{{Field: "reason", Code: "required"}}}
}
if raw, ok := fields["session_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return value, ValidationError{Violations: []FieldViolation{{Field: "session_id", Code: "required"}}}
}
if raw, ok := fields["text_bytes"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return value, ValidationError{Violations: []FieldViolation{{Field: "text_bytes", Code: "required"}}}
}
if raw, ok := fields["version"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return value, ValidationError{Violations: []FieldViolation{{Field: "version", Code: "required"}}}
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&value); err != nil {
return value, err
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
if err == nil {
return value, errors.New("trailing JSON value")
}
return value, err
}
if err := value.Validate(); err != nil {
return value, err
}
return value, nil
}
func EncodeGatewayClipboardAudit(value GatewayClipboardAudit) ([]byte, error) {
if err := value.Validate(); err != nil {
return nil, err
}
return json.Marshal(value)
}
func (v GatewayClipboardText) Validate() error {
var violations []FieldViolation
if v.Direction == "" {
violations = append(violations, FieldViolation{Field: "direction", Code: "required"})
}
if v.Direction != "" && !(v.Direction == "client_to_provider" || v.Direction == "provider_to_client") {
violations = append(violations, FieldViolation{Field: "direction", Code: "invalid_value"})
}
if v.Text == "" {
violations = append(violations, FieldViolation{Field: "text", Code: "required"})
}
if len(v.Text) > 65536 {
violations = append(violations, FieldViolation{Field: "text", Code: "max_length"})
}
if len(v.Text) > 65536 {
violations = append(violations, FieldViolation{Field: "text", Code: "max_bytes"})
}
if v.Encoding == "" {
violations = append(violations, FieldViolation{Field: "encoding", Code: "required"})
}
if v.Encoding != "utf-8" && v.Encoding != "" {
violations = append(violations, FieldViolation{Field: "encoding", Code: "invalid_value"})
}
if v.LoopToken == "" {
violations = append(violations, FieldViolation{Field: "loop_token", Code: "required"})
}
if len(v.LoopToken) < 16 && v.LoopToken != "" {
violations = append(violations, FieldViolation{Field: "loop_token", Code: "min_length"})
}
if len(v.LoopToken) > 128 {
violations = append(violations, FieldViolation{Field: "loop_token", Code: "max_length"})
}
if v.LoopToken != "" {
if _, err := base64.RawURLEncoding.Strict().DecodeString(v.LoopToken); err != nil {
violations = append(violations, FieldViolation{Field: "loop_token", Code: "invalid_format"})
}
}
if len(violations) > 0 {
return ValidationError{Violations: violations}
}
return nil
}
func DecodeGatewayClipboardText(data []byte) (GatewayClipboardText, error) {
var value GatewayClipboardText
if len(data) > 1024*1024 {
return value, errors.New("protocol payload exceeds limit")
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(data, &fields); err != nil {
return value, err
}
if raw, ok := fields["direction"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return value, ValidationError{Violations: []FieldViolation{{Field: "direction", Code: "required"}}}
}
if raw, ok := fields["encoding"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return value, ValidationError{Violations: []FieldViolation{{Field: "encoding", Code: "required"}}}
}
if raw, ok := fields["loop_token"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return value, ValidationError{Violations: []FieldViolation{{Field: "loop_token", Code: "required"}}}
}
if raw, ok := fields["text"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return value, ValidationError{Violations: []FieldViolation{{Field: "text", Code: "required"}}}
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&value); err != nil {
return value, err
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
if err == nil {
return value, errors.New("trailing JSON value")
}
return value, err
}
if err := value.Validate(); err != nil {
return value, err
}
return value, nil
}
func EncodeGatewayClipboardText(value GatewayClipboardText) ([]byte, error) {
if err := value.Validate(); err != nil {
return nil, err
}
return json.Marshal(value)
}
func (v GatewayDrain) Validate() error {
var violations []FieldViolation
if v.Version == "" {
@@ -3082,6 +3368,12 @@ func (v ProviderSessionWork) Validate() error {
if len(v.ServerCertificatePem) > 32768 {
violations = append(violations, FieldViolation{Field: "server_certificate_pem", Code: "max_length"})
}
if reflect.DeepEqual(v.ClipboardPolicy, ClipboardPolicy{}) {
violations = append(violations, FieldViolation{Field: "clipboard_policy", Code: "required"})
}
if err := v.ClipboardPolicy.Validate(); err != nil {
violations = append(violations, FieldViolation{Field: "clipboard_policy", Code: "invalid_object"})
}
if len(violations) > 0 {
return ValidationError{Violations: violations}
}
@@ -3109,6 +3401,9 @@ func DecodeProviderSessionWork(data []byte) (ProviderSessionWork, error) {
if raw, ok := fields["client_private_key_pem"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return value, ValidationError{Violations: []FieldViolation{{Field: "client_private_key_pem", Code: "required"}}}
}
if raw, ok := fields["clipboard_policy"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return value, ValidationError{Violations: []FieldViolation{{Field: "clipboard_policy", Code: "required"}}}
}
if raw, ok := fields["expires_at"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return value, ValidationError{Violations: []FieldViolation{{Field: "expires_at", Code: "required"}}}
}
@@ -3124,6 +3419,9 @@ func DecodeProviderSessionWork(data []byte) (ProviderSessionWork, error) {
if raw, ok := fields["policy_version_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return value, ValidationError{Violations: []FieldViolation{{Field: "policy_version_id", Code: "required"}}}
}
if raw, ok := fields["provider_application_termination_allowed"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return value, ValidationError{Violations: []FieldViolation{{Field: "provider_application_termination_allowed", Code: "required"}}}
}
if raw, ok := fields["provider_identity"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return value, ValidationError{Violations: []FieldViolation{{Field: "provider_identity", Code: "required"}}}
}
+2 -2
View File
@@ -12,7 +12,7 @@
"2"
]
},
"generator_sha256": "e9c6ee1541585fcb00dcc5e94a5a6d93dbe3a719a5c545f31e5eda268f2638ab",
"generator_sha256": "922983e07a8ecc559771778fbf139155b14664742d9873be062880102777dccb",
"protocol_version": "1.0.0",
"schema_sha256": "792abfb9cfe70e79911d499d76c009ab848713278bc240b88576df520580e480"
"schema_sha256": "e98c75ef81bbeac6be2b8f11202c1ffecec0aa515b48576a26756290e99d5dd8"
}
Binary file not shown.
Binary file not shown.
+124 -3
View File
@@ -1,6 +1,6 @@
// Code generated by tools/generate.py; DO NOT EDIT.
#![allow(non_snake_case)]
pub const SCHEMA_SHA256: &str = "792abfb9cfe70e79911d499d76c009ab848713278bc240b88576df520580e480";
pub const SCHEMA_SHA256: &str = "e98c75ef81bbeac6be2b8f11202c1ffecec0aa515b48576a26756290e99d5dd8";
pub const CURRENT_WIRE_VERSION: &str = "1";
pub const N_MINUS_1_WIRE_VERSION: &str = "0";
pub const N_MINUS_2_WIRE_VERSION: &str = "-1";
@@ -10,6 +10,27 @@ pub type JsonObject = std::collections::BTreeMap<String, String>;
pub struct ValidationError { pub field: &'static str, pub code: &'static str }
impl ValidationError { pub const fn new(field: &'static str, code: &'static str) -> Self { Self { field, code } } }
fn base64url_value(value: u8) -> Option<u8> {
match value {
b'A'..=b'Z' => Some(value - b'A'),
b'a'..=b'z' => Some(value - b'a' + 26),
b'0'..=b'9' => Some(value - b'0' + 52),
b'-' => Some(62),
b'_' => Some(63),
_ => None,
}
}
fn valid_base64_url(value: &str) -> bool {
let bytes = value.as_bytes();
if bytes.is_empty() || bytes.iter().any(|byte| base64url_value(*byte).is_none()) { return false; }
match bytes.len() % 4 {
0 => true,
2 => base64url_value(*bytes.last().unwrap()).unwrap() & 0x0f == 0,
3 => base64url_value(*bytes.last().unwrap()).unwrap() & 0x03 == 0,
_ => false,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AllocationPolicy {
minimumKbps: i64,
@@ -259,6 +280,7 @@ impl ChannelFrame {
if self.fragmentCount > 16 { return Err(ValidationError::new("fragment_count", "maximum")); }
if self.timestampMs < 0 { return Err(ValidationError::new("timestamp_ms", "minimum")); }
if self.payload.len() > 87384 { return Err(ValidationError::new("payload", "max_length")); }
if self.payload.as_bytes().len() > 65536 { return Err(ValidationError::new("payload", "max_bytes")); }
if self.fragmentIndex >= self.fragmentCount { return Err(ValidationError::new("fragment_index", "invalid_order")); }
Ok(())
}
@@ -272,6 +294,33 @@ impl ChannelFrame {
pub fn payload(&self) -> &String { &self.payload }
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClipboardPolicy {
clientToProviderEnabled: bool,
providerToClientEnabled: bool,
maxTextBytes: i64,
maxUpdatesPerMinute: i64,
}
impl ClipboardPolicy {
pub fn new(clientToProviderEnabled: bool, providerToClientEnabled: bool, maxTextBytes: i64, maxUpdatesPerMinute: i64) -> Result<Self, ValidationError> {
let value = Self { clientToProviderEnabled, providerToClientEnabled, maxTextBytes, maxUpdatesPerMinute };
value.validate()?;
Ok(value)
}
pub fn validate(&self) -> Result<(), ValidationError> {
if self.maxTextBytes < 1 { return Err(ValidationError::new("max_text_bytes", "minimum")); }
if self.maxTextBytes > 65536 { return Err(ValidationError::new("max_text_bytes", "maximum")); }
if self.maxUpdatesPerMinute < 1 { return Err(ValidationError::new("max_updates_per_minute", "minimum")); }
if self.maxUpdatesPerMinute > 120 { return Err(ValidationError::new("max_updates_per_minute", "maximum")); }
Ok(())
}
pub fn clientToProviderEnabled(&self) -> &bool { &self.clientToProviderEnabled }
pub fn providerToClientEnabled(&self) -> &bool { &self.providerToClientEnabled }
pub fn maxTextBytes(&self) -> &i64 { &self.maxTextBytes }
pub fn maxUpdatesPerMinute(&self) -> &i64 { &self.maxUpdatesPerMinute }
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClipboardText {
text: String,
@@ -612,6 +661,73 @@ impl FieldViolation {
pub fn code(&self) -> &String { &self.code }
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GatewayClipboardAudit {
version: String,
sessionId: String,
direction: String,
outcome: String,
textBytes: i64,
reason: String,
}
impl GatewayClipboardAudit {
pub fn new(version: String, sessionId: String, direction: String, outcome: String, textBytes: i64, reason: String) -> Result<Self, ValidationError> {
let value = Self { version, sessionId, direction, outcome, textBytes, reason };
value.validate()?;
Ok(value)
}
pub fn validate(&self) -> Result<(), ValidationError> {
if self.version != "1" { return Err(ValidationError::new("version", "invalid_value")); }
if self.sessionId.is_empty() { return Err(ValidationError::new("session_id", "required")); }
if !self.sessionId.is_empty() && self.sessionId.len() < 1 { return Err(ValidationError::new("session_id", "min_length")); }
if self.sessionId.len() > 128 { return Err(ValidationError::new("session_id", "max_length")); }
if self.direction != "client_to_provider" && self.direction != "provider_to_client" { return Err(ValidationError::new("direction", "invalid_value")); }
if self.outcome != "forwarded" && self.outcome != "suppressed" && self.outcome != "rejected" { return Err(ValidationError::new("outcome", "invalid_value")); }
if self.textBytes < 0 { return Err(ValidationError::new("text_bytes", "minimum")); }
if self.textBytes > 65536 { return Err(ValidationError::new("text_bytes", "maximum")); }
if self.reason != "forwarded" && self.reason != "loop" && self.reason != "policy" && self.reason != "rate" && self.reason != "provider" && self.reason != "malformed" { return Err(ValidationError::new("reason", "invalid_value")); }
Ok(())
}
pub fn version(&self) -> &String { &self.version }
pub fn sessionId(&self) -> &String { &self.sessionId }
pub fn direction(&self) -> &String { &self.direction }
pub fn outcome(&self) -> &String { &self.outcome }
pub fn textBytes(&self) -> &i64 { &self.textBytes }
pub fn reason(&self) -> &String { &self.reason }
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GatewayClipboardText {
direction: String,
text: String,
encoding: String,
loopToken: String,
}
impl GatewayClipboardText {
pub fn new(direction: String, text: String, encoding: String, loopToken: String) -> Result<Self, ValidationError> {
let value = Self { direction, text, encoding, loopToken };
value.validate()?;
Ok(value)
}
pub fn validate(&self) -> Result<(), ValidationError> {
if self.direction != "client_to_provider" && self.direction != "provider_to_client" { return Err(ValidationError::new("direction", "invalid_value")); }
if self.text.len() > 65536 { return Err(ValidationError::new("text", "max_length")); }
if self.text.as_bytes().len() > 65536 { return Err(ValidationError::new("text", "max_bytes")); }
if self.encoding != "utf-8" { return Err(ValidationError::new("encoding", "invalid_value")); }
if self.loopToken.is_empty() { return Err(ValidationError::new("loop_token", "required")); }
if !self.loopToken.is_empty() && self.loopToken.len() < 16 { return Err(ValidationError::new("loop_token", "min_length")); }
if self.loopToken.len() > 128 { return Err(ValidationError::new("loop_token", "max_length")); }
if !valid_base64_url(self.loopToken.as_str()) { return Err(ValidationError::new("loop_token", "invalid_format")); }
Ok(())
}
pub fn direction(&self) -> &String { &self.direction }
pub fn text(&self) -> &String { &self.text }
pub fn encoding(&self) -> &String { &self.encoding }
pub fn loopToken(&self) -> &String { &self.loopToken }
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GatewayDrain {
version: String,
@@ -1001,11 +1117,13 @@ pub struct ProviderSessionWork {
clientCertificatePem: String,
clientPrivateKeyPem: String,
serverCertificatePem: String,
clipboardPolicy: ClipboardPolicy,
providerApplicationTerminationAllowed: bool,
}
impl ProviderSessionWork {
pub fn new(version: String, sessionId: String, gatewayId: String, reconnectSequence: i64, expiresAt: String, providerProfile: String, providerIdentity: String, policyVersionId: String, applicationId: String, clientId: String, managementHost: String, managementPort: i64, streamHost: String, streamPort: i64, clientCertificatePem: String, clientPrivateKeyPem: String, serverCertificatePem: String) -> Result<Self, ValidationError> {
let value = Self { version, sessionId, gatewayId, reconnectSequence, expiresAt, providerProfile, providerIdentity, policyVersionId, applicationId, clientId, managementHost, managementPort, streamHost, streamPort, clientCertificatePem, clientPrivateKeyPem, serverCertificatePem };
pub fn new(version: String, sessionId: String, gatewayId: String, reconnectSequence: i64, expiresAt: String, providerProfile: String, providerIdentity: String, policyVersionId: String, applicationId: String, clientId: String, managementHost: String, managementPort: i64, streamHost: String, streamPort: i64, clientCertificatePem: String, clientPrivateKeyPem: String, serverCertificatePem: String, clipboardPolicy: ClipboardPolicy, providerApplicationTerminationAllowed: bool) -> Result<Self, ValidationError> {
let value = Self { version, sessionId, gatewayId, reconnectSequence, expiresAt, providerProfile, providerIdentity, policyVersionId, applicationId, clientId, managementHost, managementPort, streamHost, streamPort, clientCertificatePem, clientPrivateKeyPem, serverCertificatePem, clipboardPolicy, providerApplicationTerminationAllowed };
value.validate()?;
Ok(value)
}
@@ -1051,6 +1169,7 @@ impl ProviderSessionWork {
if self.serverCertificatePem.is_empty() { return Err(ValidationError::new("server_certificate_pem", "required")); }
if !self.serverCertificatePem.is_empty() && self.serverCertificatePem.len() < 1 { return Err(ValidationError::new("server_certificate_pem", "min_length")); }
if self.serverCertificatePem.len() > 32768 { return Err(ValidationError::new("server_certificate_pem", "max_length")); }
self.clipboardPolicy.validate().map_err(|_| ValidationError::new("clipboard_policy", "invalid_object"))?;
Ok(())
}
pub fn version(&self) -> &String { &self.version }
@@ -1070,6 +1189,8 @@ impl ProviderSessionWork {
pub fn clientCertificatePem(&self) -> &String { &self.clientCertificatePem }
pub fn clientPrivateKeyPem(&self) -> &String { &self.clientPrivateKeyPem }
pub fn serverCertificatePem(&self) -> &String { &self.serverCertificatePem }
pub fn clipboardPolicy(&self) -> &ClipboardPolicy { &self.clipboardPolicy }
pub fn providerApplicationTerminationAllowed(&self) -> &bool { &self.providerApplicationTerminationAllowed }
}
#[derive(Debug, Clone, PartialEq, Eq)]
+149 -3
View File
@@ -1,12 +1,21 @@
// Code generated by tools/generate.py; DO NOT EDIT.
import Foundation
public typealias JSONObject = [String: String]
public let schemaSHA256 = "792abfb9cfe70e79911d499d76c009ab848713278bc240b88576df520580e480"
public let schemaSHA256 = "e98c75ef81bbeac6be2b8f11202c1ffecec0aa515b48576a26756290e99d5dd8"
public let currentWireVersion = "1"
public let nMinus1WireVersion = "0"
public let nMinus2WireVersion = "-1"
public struct ContractValidationError: Error, Equatable { public let field: String; public let code: String }
private struct AnyCodingKey: CodingKey { let stringValue: String; let intValue: Int?; init?(stringValue: String) { self.stringValue = stringValue; self.intValue = nil }; init?(intValue: Int) { self.stringValue = String(intValue); self.intValue = intValue } }
private func validBase64URL(_ value: String) -> Bool {
guard !value.isEmpty, value.utf8.allSatisfy({ byte in
(byte >= 65 && byte <= 90) || (byte >= 97 && byte <= 122) || (byte >= 48 && byte <= 57) || byte == 45 || byte == 95
}) else { return false }
let padding = String(repeating: "=", count: (4 - value.utf8.count % 4) % 4)
let standard = value.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + padding
guard let decoded = Data(base64Encoded: standard) else { return false }
return decoded.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "") == value
}
public struct AllocationPolicy: Codable, Equatable {
public let minimumKbps: Int64
@@ -343,6 +352,7 @@ public struct ChannelFrame: Codable, Equatable {
if self.fragmentCount > 16 { throw ContractValidationError(field: "fragment_count", code: "maximum") }
if self.timestampMs < 0 { throw ContractValidationError(field: "timestamp_ms", code: "minimum") }
if self.payload.utf8.count > 87384 { throw ContractValidationError(field: "payload", code: "max_length") }
if self.payload.utf8.count > 65536 { throw ContractValidationError(field: "payload", code: "max_bytes") }
if fragmentIndex >= fragmentCount { throw ContractValidationError(field: "fragment_index", code: "invalid_order") }
}
@@ -350,6 +360,44 @@ public struct ChannelFrame: Codable, Equatable {
public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }
}
public struct ClipboardPolicy: Codable, Equatable {
public let clientToProviderEnabled: Bool
public let providerToClientEnabled: Bool
public let maxTextBytes: Int64
public let maxUpdatesPerMinute: Int64
enum CodingKeys: String, CodingKey {
case clientToProviderEnabled = "client_to_provider_enabled"
case providerToClientEnabled = "provider_to_client_enabled"
case maxTextBytes = "max_text_bytes"
case maxUpdatesPerMinute = "max_updates_per_minute"
}
public init(clientToProviderEnabled: Bool, providerToClientEnabled: Bool, maxTextBytes: Int64, maxUpdatesPerMinute: Int64) throws {
self.clientToProviderEnabled = clientToProviderEnabled
self.providerToClientEnabled = providerToClientEnabled
self.maxTextBytes = maxTextBytes
self.maxUpdatesPerMinute = maxUpdatesPerMinute
try validate()
}
public init(from decoder: Decoder) throws {
let all = try decoder.container(keyedBy: AnyCodingKey.self)
for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") }
let c = try decoder.container(keyedBy: CodingKeys.self)
try self.init(clientToProviderEnabled: try c.decode(Bool.self, forKey: .clientToProviderEnabled), providerToClientEnabled: try c.decode(Bool.self, forKey: .providerToClientEnabled), maxTextBytes: try c.decode(Int64.self, forKey: .maxTextBytes), maxUpdatesPerMinute: try c.decode(Int64.self, forKey: .maxUpdatesPerMinute))
}
public func validate() throws {
if self.maxTextBytes < 1 { throw ContractValidationError(field: "max_text_bytes", code: "minimum") }
if self.maxTextBytes > 65536 { throw ContractValidationError(field: "max_text_bytes", code: "maximum") }
if self.maxUpdatesPerMinute < 1 { throw ContractValidationError(field: "max_updates_per_minute", code: "minimum") }
if self.maxUpdatesPerMinute > 120 { throw ContractValidationError(field: "max_updates_per_minute", code: "maximum") }
}
public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }
public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }
}
public struct ClipboardText: Codable, Equatable {
public let text: String
public let encoding: String
@@ -809,6 +857,97 @@ public struct FieldViolation: Codable, Equatable {
public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }
}
public struct GatewayClipboardAudit: Codable, Equatable {
public let version: String
public let sessionId: String
public let direction: String
public let outcome: String
public let textBytes: Int64
public let reason: String
enum CodingKeys: String, CodingKey {
case version = "version"
case sessionId = "session_id"
case direction = "direction"
case outcome = "outcome"
case textBytes = "text_bytes"
case reason = "reason"
}
public init(version: String, sessionId: String, direction: String, outcome: String, textBytes: Int64, reason: String) throws {
self.version = version
self.sessionId = sessionId
self.direction = direction
self.outcome = outcome
self.textBytes = textBytes
self.reason = reason
try validate()
}
public init(from decoder: Decoder) throws {
let all = try decoder.container(keyedBy: AnyCodingKey.self)
for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") }
let c = try decoder.container(keyedBy: CodingKeys.self)
try self.init(version: try c.decode(String.self, forKey: .version), sessionId: try c.decode(String.self, forKey: .sessionId), direction: try c.decode(String.self, forKey: .direction), outcome: try c.decode(String.self, forKey: .outcome), textBytes: try c.decode(Int64.self, forKey: .textBytes), reason: try c.decode(String.self, forKey: .reason))
}
public func validate() throws {
if self.version != "1" { throw ContractValidationError(field: "version", code: "invalid_value") }
if self.sessionId.isEmpty { throw ContractValidationError(field: "session_id", code: "required") }
if !self.sessionId.isEmpty && self.sessionId.utf8.count < 1 { throw ContractValidationError(field: "session_id", code: "min_length") }
if self.sessionId.utf8.count > 128 { throw ContractValidationError(field: "session_id", code: "max_length") }
if !["client_to_provider", "provider_to_client"].contains(self.direction) { throw ContractValidationError(field: "direction", code: "invalid_value") }
if !["forwarded", "suppressed", "rejected"].contains(self.outcome) { throw ContractValidationError(field: "outcome", code: "invalid_value") }
if self.textBytes < 0 { throw ContractValidationError(field: "text_bytes", code: "minimum") }
if self.textBytes > 65536 { throw ContractValidationError(field: "text_bytes", code: "maximum") }
if !["forwarded", "loop", "policy", "rate", "provider", "malformed"].contains(self.reason) { throw ContractValidationError(field: "reason", code: "invalid_value") }
}
public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }
public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }
}
public struct GatewayClipboardText: Codable, Equatable {
public let direction: String
public let text: String
public let encoding: String
public let loopToken: String
enum CodingKeys: String, CodingKey {
case direction = "direction"
case text = "text"
case encoding = "encoding"
case loopToken = "loop_token"
}
public init(direction: String, text: String, encoding: String, loopToken: String) throws {
self.direction = direction
self.text = text
self.encoding = encoding
self.loopToken = loopToken
try validate()
}
public init(from decoder: Decoder) throws {
let all = try decoder.container(keyedBy: AnyCodingKey.self)
for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") }
let c = try decoder.container(keyedBy: CodingKeys.self)
try self.init(direction: try c.decode(String.self, forKey: .direction), text: try c.decode(String.self, forKey: .text), encoding: try c.decode(String.self, forKey: .encoding), loopToken: try c.decode(String.self, forKey: .loopToken))
}
public func validate() throws {
if !["client_to_provider", "provider_to_client"].contains(self.direction) { throw ContractValidationError(field: "direction", code: "invalid_value") }
if self.text.utf8.count > 65536 { throw ContractValidationError(field: "text", code: "max_length") }
if self.text.utf8.count > 65536 { throw ContractValidationError(field: "text", code: "max_bytes") }
if self.encoding != "utf-8" { throw ContractValidationError(field: "encoding", code: "invalid_value") }
if self.loopToken.isEmpty { throw ContractValidationError(field: "loop_token", code: "required") }
if !self.loopToken.isEmpty && self.loopToken.utf8.count < 16 { throw ContractValidationError(field: "loop_token", code: "min_length") }
if self.loopToken.utf8.count > 128 { throw ContractValidationError(field: "loop_token", code: "max_length") }
if !validBase64URL(self.loopToken) { throw ContractValidationError(field: "loop_token", code: "invalid_format") }
}
public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }
public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }
}
public struct GatewayDrain: Codable, Equatable {
public let version: String
public let gatewayId: String
@@ -1328,6 +1467,8 @@ public struct ProviderSessionWork: Codable, Equatable {
public let clientCertificatePem: String
public let clientPrivateKeyPem: String
public let serverCertificatePem: String
public let clipboardPolicy: ClipboardPolicy
public let providerApplicationTerminationAllowed: Bool
enum CodingKeys: String, CodingKey {
case version = "version"
case sessionId = "session_id"
@@ -1346,9 +1487,11 @@ public struct ProviderSessionWork: Codable, Equatable {
case clientCertificatePem = "client_certificate_pem"
case clientPrivateKeyPem = "client_private_key_pem"
case serverCertificatePem = "server_certificate_pem"
case clipboardPolicy = "clipboard_policy"
case providerApplicationTerminationAllowed = "provider_application_termination_allowed"
}
public init(version: String, sessionId: String, gatewayId: String, reconnectSequence: Int64, expiresAt: String, providerProfile: String, providerIdentity: String, policyVersionId: String, applicationId: String, clientId: String, managementHost: String, managementPort: Int64, streamHost: String, streamPort: Int64, clientCertificatePem: String, clientPrivateKeyPem: String, serverCertificatePem: String) throws {
public init(version: String, sessionId: String, gatewayId: String, reconnectSequence: Int64, expiresAt: String, providerProfile: String, providerIdentity: String, policyVersionId: String, applicationId: String, clientId: String, managementHost: String, managementPort: Int64, streamHost: String, streamPort: Int64, clientCertificatePem: String, clientPrivateKeyPem: String, serverCertificatePem: String, clipboardPolicy: ClipboardPolicy, providerApplicationTerminationAllowed: Bool) throws {
self.version = version
self.sessionId = sessionId
self.gatewayId = gatewayId
@@ -1366,6 +1509,8 @@ public struct ProviderSessionWork: Codable, Equatable {
self.clientCertificatePem = clientCertificatePem
self.clientPrivateKeyPem = clientPrivateKeyPem
self.serverCertificatePem = serverCertificatePem
self.clipboardPolicy = clipboardPolicy
self.providerApplicationTerminationAllowed = providerApplicationTerminationAllowed
try validate()
}
@@ -1373,7 +1518,7 @@ public struct ProviderSessionWork: Codable, Equatable {
let all = try decoder.container(keyedBy: AnyCodingKey.self)
for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") }
let c = try decoder.container(keyedBy: CodingKeys.self)
try self.init(version: try c.decode(String.self, forKey: .version), sessionId: try c.decode(String.self, forKey: .sessionId), gatewayId: try c.decode(String.self, forKey: .gatewayId), reconnectSequence: try c.decode(Int64.self, forKey: .reconnectSequence), expiresAt: try c.decode(String.self, forKey: .expiresAt), providerProfile: try c.decode(String.self, forKey: .providerProfile), providerIdentity: try c.decode(String.self, forKey: .providerIdentity), policyVersionId: try c.decode(String.self, forKey: .policyVersionId), applicationId: try c.decode(String.self, forKey: .applicationId), clientId: try c.decode(String.self, forKey: .clientId), managementHost: try c.decode(String.self, forKey: .managementHost), managementPort: try c.decode(Int64.self, forKey: .managementPort), streamHost: try c.decode(String.self, forKey: .streamHost), streamPort: try c.decode(Int64.self, forKey: .streamPort), clientCertificatePem: try c.decode(String.self, forKey: .clientCertificatePem), clientPrivateKeyPem: try c.decode(String.self, forKey: .clientPrivateKeyPem), serverCertificatePem: try c.decode(String.self, forKey: .serverCertificatePem))
try self.init(version: try c.decode(String.self, forKey: .version), sessionId: try c.decode(String.self, forKey: .sessionId), gatewayId: try c.decode(String.self, forKey: .gatewayId), reconnectSequence: try c.decode(Int64.self, forKey: .reconnectSequence), expiresAt: try c.decode(String.self, forKey: .expiresAt), providerProfile: try c.decode(String.self, forKey: .providerProfile), providerIdentity: try c.decode(String.self, forKey: .providerIdentity), policyVersionId: try c.decode(String.self, forKey: .policyVersionId), applicationId: try c.decode(String.self, forKey: .applicationId), clientId: try c.decode(String.self, forKey: .clientId), managementHost: try c.decode(String.self, forKey: .managementHost), managementPort: try c.decode(Int64.self, forKey: .managementPort), streamHost: try c.decode(String.self, forKey: .streamHost), streamPort: try c.decode(Int64.self, forKey: .streamPort), clientCertificatePem: try c.decode(String.self, forKey: .clientCertificatePem), clientPrivateKeyPem: try c.decode(String.self, forKey: .clientPrivateKeyPem), serverCertificatePem: try c.decode(String.self, forKey: .serverCertificatePem), clipboardPolicy: try c.decode(ClipboardPolicy.self, forKey: .clipboardPolicy), providerApplicationTerminationAllowed: try c.decode(Bool.self, forKey: .providerApplicationTerminationAllowed))
}
public func validate() throws {
@@ -1419,6 +1564,7 @@ public struct ProviderSessionWork: Codable, Equatable {
if self.serverCertificatePem.isEmpty { throw ContractValidationError(field: "server_certificate_pem", code: "required") }
if !self.serverCertificatePem.isEmpty && self.serverCertificatePem.utf8.count < 1 { throw ContractValidationError(field: "server_certificate_pem", code: "min_length") }
if self.serverCertificatePem.utf8.count > 32768 { throw ContractValidationError(field: "server_certificate_pem", code: "max_length") }
try self.clipboardPolicy.validate()
}
public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-29
@@ -0,0 +1,50 @@
## Context
The registered `input.sequenced.v1` flow has a payload bound but no typed
payload grammar. RC5 also has no provider-to-client envelope for host
termination, rumble, or HDR feedback. The Data Plane must translate these
states to the provider without exposing Apollo packet formats or credentials.
## Goals / Non-Goals
**Goals:**
- Define fixed, bounded, endian-explicit gateway payloads for keyboard, mouse,
UTF-8 text, and controller state.
- Define a reliable control envelope for provider feedback and termination.
- Define an explicit release operation for every pressed key/button/controller.
- Keep provider packet encodings and endpoint details internal to the Data Plane.
**Non-Goals:**
- Touch, pen, motion, file transfer, binary clipboard, or provider-specific
packets.
- Changing RC5, moving an existing tag, or making the Connection Server parse
streaming input.
## Decisions
- Define a new binary payload grammar beneath the existing registered flows.
This avoids changing the authenticated tunnel header while removing the
untyped `device`/opaque-payload ambiguity. JSON was rejected because input is
latency-sensitive and fixed binary bounds are simpler to validate before
allocation.
- Input events use explicit event kinds and fixed payload lengths except UTF-8
text, which is limited to one valid Unicode scalar value. This permits exact
provider translation and deterministic cleanup.
- Provider feedback and termination use the existing bidirectional reliable
control channel with a distinct magic, direction, type, and length. A new
channel was rejected because the existing channel is already authenticated,
reliable, and versioned.
- Protocol contents and fixtures are finalized before a new immutable release
candidate is created. RC5 remains an unchanged dependency for current
consumers until they explicitly adopt the new revision.
## Risks / Trade-offs
- [New client adoption is required] -> retain RC5 unchanged and publish an
explicit capability/version mismatch before any provider allocation.
- [Provider feedback can be high rate] -> allow only termination, rumble, and
HDR payload types with fixed maximum sizes; other types reject.
- [Pressed-state loss during disconnect] -> gateway records accepted presses
and emits typed releases during cleanup before provider disconnect.
@@ -0,0 +1,40 @@
## Why
The RC5 tunnel register identifies a bounded sequenced-input flow but does not
define typed input state or provider-to-client feedback. The native Apollo
adapter cannot safely translate keyboard, mouse, UTF-8, controller, termination,
rumble, or HDR state from an untyped payload.
## What Changes
- Define a typed, versioned gateway input envelope for keyboard, mouse, UTF-8,
and controller state.
- Define bounded provider-feedback and provider-termination envelopes on the
existing reliable control direction.
- Define a separate typed clipboard envelope and Server-owned per-session
direction, size, and rate policy so the gateway can prevent reflected loops
without exposing provider management material.
- Define release semantics so gateway cleanup can emit real provider key/button
releases without a synthetic provider command.
- Preserve RC5 unchanged; this change requires a new immutable Protocol version
after its fixtures and consumers are final.
## Capabilities
### New Capabilities
- `gateway-input-feedback`: Typed Phase 3C gateway input, provider feedback,
termination, and release envelopes.
### Modified Capabilities
- None.
## Impact
- Protocol control and datagram registries, schemas, fixtures, and generated
Go/Rust/Swift bindings.
- Data Plane gateway input/clipboard translation and host-feedback forwarding.
- Connection Server mints only immutable clipboard policy in authenticated
provider work; it neither receives clipboard bytes nor inspects provider
packet payloads.
@@ -0,0 +1,79 @@
## ADDED Requirements
### Requirement: Typed sequenced input envelope
The `input.sequenced.v1` payload SHALL begin with ASCII `VGI1`, a one-byte
event kind, and one-byte payload length. It SHALL contain exactly one bounded
keyboard, mouse-button, relative-mouse, UTF-8 scalar, or controller-state
event. False keyboard/mouse state and zeroed controller state are explicit
releases. Multibyte integer fields SHALL be big-endian. Unknown kinds,
length mismatches, malformed UTF-8, unsupported controller indices, and
reserved fields SHALL be rejected before provider translation.
#### Scenario: Keyboard state change
- **WHEN** a client sends a valid keyboard press or release envelope
- **THEN** the gateway forwards the corresponding typed provider input on its
reliable keyboard channel and records the pressed state for cleanup.
#### Scenario: Invalid input envelope
- **WHEN** a client sends an envelope with an unknown event kind, invalid
length, malformed UTF-8 scalar, or nonzero reserved field
- **THEN** the gateway rejects it without sending provider input or changing
pressed state.
### Requirement: Explicit input release
The typed input envelope SHALL represent release of each keyboard key,
mouse button, and controller state. Gateway cleanup SHALL send a typed release
for every accepted pressed state before provider disconnect; it SHALL NOT use
an implementation-specific release-all provider command.
#### Scenario: Tunnel cleanup with pressed input
- **WHEN** a tunnel closes after accepted pressed keyboard, mouse, or
controller input
- **THEN** the gateway emits the corresponding individual provider release
packets reliably before starting provider disconnect.
### Requirement: Bounded provider feedback control envelope
The registered bidirectional reliable `control.ack.v1` flow SHALL define an ASCII `VGF1` envelope
with a direction byte, type byte, big-endian payload length, and exact payload
bytes. Only host termination, rumble, and HDR feedback SHALL be valid from the
gateway to the client; only IDR and FEC/loss feedback SHALL be valid from the
client to the gateway. The envelope SHALL contain no provider address,
certificate, credential, or opaque provider packet.
#### Scenario: Host termination forwarding
- **WHEN** the Apollo adapter receives an authenticated host termination
packet
- **THEN** the gateway forwards a bounded `VGF1` termination envelope over
reliable Verse control and reports the provider state separately.
#### Scenario: Unauthorized or malformed feedback
- **WHEN** feedback is disabled by policy, has an invalid direction/type/length,
or contains a forbidden provider field
- **THEN** the gateway rejects it without forwarding or provider mutation.
### Requirement: Policy-bound text clipboard envelope
The reliable `clipboard.text.v1` flow SHALL carry only a typed UTF-8 text
envelope with exact direction and a 16--128 character canonical unpadded ASCII
base64url loop token. The Server SHALL mint
the enabled directions, maximum text bytes, and maximum updates per minute in
authenticated provider work. The gateway SHALL reject disabled direction,
unknown fields, files, file URLs, client folders, binary data, malformed UTF-8,
oversized values, rates above policy, and reflected/replayed loop tokens. It
SHALL not put clipboard content, provider routes, or credentials in telemetry,
audit, state, or errors.
#### Scenario: Clipboard audit metadata
- **WHEN** the gateway successfully delivers, suppresses, or rejects a clipboard update
- **THEN** it sends an authenticated Server audit record with only direction,
bounded byte count, outcome, and a fixed reason; it never includes text or
the loop token.
#### Scenario: Clipboard delivery failure
- **WHEN** provider-to-client control delivery fails
- **THEN** the gateway does not report the update as forwarded.
#### Scenario: Reflected clipboard value
- **WHEN** a client-originated text value returns from the provider with the
matching retained token/value pair
- **THEN** the gateway suppresses the reflected update without a second
provider mutation or client delivery.
@@ -0,0 +1,29 @@
## 1. Contract and fixtures
- [x] 1.1 Define the exact typed input and provider-feedback binary layouts in
the canonical frame documentation and registries.
- [x] 1.2 Add positive and negative fixed-byte conformance fixtures for every
input, release, feedback, termination, reserved, and malformed case.
- [x] 1.3 Update only source Protocol artifacts, regenerate bindings and the
manifest, and prove no generated drift.
- [x] 1.4 Define policy-bound clipboard direction/rate/loop-token envelopes and
positive/negative deterministic conformance fixtures without adding provider
fields to a client-facing frame.
## 2. Consumer qualification
- [x] 2.1 Run Protocol validation and the Go, Rust, and Swift conformance
consumers against the new fixtures.
- [ ] 2.2 Advance the Data Plane to the final immutable Protocol revision and
translate only the typed envelopes to provider control packets.
- [x] 2.3 Add deterministic host-feedback forwarding and input-release tests
without provider endpoint or credential disclosure.
- [ ] 2.4 Advance the Data Plane and Connection Server to the final clipboard
contract and prove disabled direction, malformed/oversized text, rate, loop,
and file/binary rejection against the authenticated provider path.
## 3. Freeze
- [ ] 3.1 Reconcile the canonical specification, OpenSpec tasks, source
provenance, and consumer fixture digest before creating a new immutable
Protocol release candidate.
+7
View File
@@ -32,3 +32,10 @@ message ClipboardText {
string text = 1;
string encoding = 2;
}
message GatewayClipboardText {
string direction = 1;
string text = 2;
string encoding = 3;
string loop_token = 4;
}
+18
View File
@@ -135,6 +135,15 @@ message ProviderSessionWork {
string client_private_key_pem = 15;
string server_certificate_pem = 16;
string client_id = 17;
ClipboardPolicy clipboard_policy = 18;
bool provider_application_termination_allowed = 19;
}
message ClipboardPolicy {
bool client_to_provider_enabled = 1;
bool provider_to_client_enabled = 2;
uint32 max_text_bytes = 3;
uint32 max_updates_per_minute = 4;
}
message ChannelFrame {
@@ -156,6 +165,15 @@ message ProviderState {
repeated string channels = 5;
}
message GatewayClipboardAudit {
string version = 1;
string session_id = 2;
string direction = 3;
string outcome = 4;
uint32 text_bytes = 5;
string reason = 6;
}
message StableError {
string version = 1;
string code = 2;
+2 -2
View File
@@ -2,11 +2,11 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"max_frame_bytes": 65536,
"datagrams": [
{"id": "control.ack.v1", "direction": "bidirectional", "max_payload_bytes": 1024},
{"id": "control.ack.v1", "direction": "bidirectional", "max_payload_bytes": 1024, "payload_profile": "gateway-feedback-v1"},
{"id": "control.cancel.v1", "direction": "client-to-server", "max_payload_bytes": 2048},
{"id": "clipboard.text.v1", "direction": "bidirectional", "max_payload_bytes": 65536},
{"id": "media.video.v1", "direction": "server-to-client", "max_payload_bytes": 1200},
{"id": "media.audio.v1", "direction": "server-to-client", "max_payload_bytes": 1200},
{"id": "input.sequenced.v1", "direction": "client-to-server", "max_payload_bytes": 1200}
{"id": "input.sequenced.v1", "direction": "client-to-server", "max_payload_bytes": 1200, "payload_profile": "gateway-input-v1"}
]
}
+39 -2
View File
@@ -335,6 +335,28 @@
"encoding": {"type": "string", "const": "utf-8"}
}
},
"ClipboardPolicy": {
"type": "object",
"additionalProperties": false,
"required": ["client_to_provider_enabled", "provider_to_client_enabled", "max_text_bytes", "max_updates_per_minute"],
"properties": {
"client_to_provider_enabled": {"type": "boolean"},
"provider_to_client_enabled": {"type": "boolean"},
"max_text_bytes": {"type": "integer", "minimum": 1, "maximum": 65536},
"max_updates_per_minute": {"type": "integer", "minimum": 1, "maximum": 120}
}
},
"GatewayClipboardText": {
"type": "object",
"additionalProperties": false,
"required": ["direction", "text", "encoding", "loop_token"],
"properties": {
"direction": {"type": "string", "enum": ["client_to_provider", "provider_to_client"]},
"text": {"type": "string", "maxLength": 65536, "x-max-bytes": 65536},
"encoding": {"type": "string", "const": "utf-8"},
"loop_token": {"type": "string", "format": "base64url", "minLength": 16, "maxLength": 128}
}
},
"VersionNegotiation": {
"type": "object",
"additionalProperties": false,
@@ -438,7 +460,7 @@
"ProviderSessionWork": {
"type": "object",
"additionalProperties": false,
"required": ["version", "session_id", "gateway_id", "reconnect_sequence", "expires_at", "provider_profile", "provider_identity", "policy_version_id", "application_id", "client_id", "management_host", "management_port", "stream_host", "stream_port", "client_certificate_pem", "client_private_key_pem", "server_certificate_pem"],
"required": ["version", "session_id", "gateway_id", "reconnect_sequence", "expires_at", "provider_profile", "provider_identity", "policy_version_id", "application_id", "client_id", "management_host", "management_port", "stream_host", "stream_port", "client_certificate_pem", "client_private_key_pem", "server_certificate_pem", "clipboard_policy", "provider_application_termination_allowed"],
"properties": {
"version": {"type": "string", "const": "1"},
"session_id": {"type": "string", "minLength": 1, "maxLength": 128},
@@ -456,7 +478,9 @@
"stream_port": {"type": "integer", "minimum": 1, "maximum": 65535},
"client_certificate_pem": {"type": "string", "minLength": 1, "maxLength": 32768},
"client_private_key_pem": {"type": "string", "minLength": 1, "maxLength": 32768},
"server_certificate_pem": {"type": "string", "minLength": 1, "maxLength": 32768}
"server_certificate_pem": {"type": "string", "minLength": 1, "maxLength": 32768},
"clipboard_policy": {"$ref": "#/$defs/ClipboardPolicy"},
"provider_application_termination_allowed": {"type": "boolean"}
}
},
"ChannelFrame": {
@@ -486,6 +510,19 @@
"channels": {"type": "array", "maxItems": 8, "items": {"type": "string", "minLength": 1, "maxLength": 64}}
}
},
"GatewayClipboardAudit": {
"type": "object",
"additionalProperties": false,
"required": ["version", "session_id", "direction", "outcome", "text_bytes", "reason"],
"properties": {
"version": {"type": "string", "const": "1"},
"session_id": {"type": "string", "minLength": 1, "maxLength": 128},
"direction": {"type": "string", "enum": ["client_to_provider", "provider_to_client"]},
"outcome": {"type": "string", "enum": ["forwarded", "suppressed", "rejected"]},
"text_bytes": {"type": "integer", "minimum": 0, "maximum": 65536},
"reason": {"type": "string", "enum": ["forwarded", "loop", "policy", "rate", "provider", "malformed"]}
}
},
"StableError": {
"type": "object",
"additionalProperties": false,
+54 -1
View File
@@ -114,11 +114,64 @@ func TestSessionAuthorityRejectsProviderRoute(t *testing.T) {
}
func TestProviderSessionWorkIsStrictAndSessionBound(t *testing.T) {
valid := `{"version":"1","session_id":"session-1","gateway_id":"gateway-1","reconnect_sequence":0,"expires_at":"2099-01-01T00:00:00Z","provider_profile":"apollo","provider_identity":"provider-1","policy_version_id":"policy-1","application_id":"42","client_id":"paired-client-1","management_host":"apollo.test","management_port":47990,"stream_host":"apollo.test","stream_port":47984,"client_certificate_pem":"certificate","client_private_key_pem":"private-key","server_certificate_pem":"server-certificate"}`
valid := `{"version":"1","session_id":"session-1","gateway_id":"gateway-1","reconnect_sequence":0,"expires_at":"2099-01-01T00:00:00Z","provider_profile":"apollo","provider_identity":"provider-1","policy_version_id":"policy-1","application_id":"42","client_id":"paired-client-1","management_host":"apollo.test","management_port":47990,"stream_host":"apollo.test","stream_port":47984,"client_certificate_pem":"certificate","client_private_key_pem":"private-key","server_certificate_pem":"server-certificate","clipboard_policy":{"client_to_provider_enabled":false,"provider_to_client_enabled":false,"max_text_bytes":65536,"max_updates_per_minute":30},"provider_application_termination_allowed":false}`
if _, err := protocol.DecodeProviderSessionWork([]byte(valid)); err != nil {
t.Fatalf("valid provider work rejected: %v", err)
}
if _, err := protocol.DecodeProviderSessionWork([]byte(strings.Replace(valid, `"application_id":"42"`, `"application_id":"42","management_password":"forbidden"`, 1))); err == nil {
t.Fatal("provider work accepted a management credential")
}
if _, err := protocol.DecodeProviderSessionWork([]byte(strings.Replace(valid, `,"clipboard_policy":{"client_to_provider_enabled":false,"provider_to_client_enabled":false,"max_text_bytes":65536,"max_updates_per_minute":30}`, "", 1))); err == nil {
t.Fatal("provider work accepted missing clipboard policy")
}
}
func TestGatewayClipboardAuditIsMetadataOnlyAndStrict(t *testing.T) {
valid := `{"version":"1","session_id":"session-1","direction":"client_to_provider","outcome":"rejected","text_bytes":64,"reason":"rate"}`
if _, err := protocol.DecodeGatewayClipboardAudit([]byte(valid)); err != nil {
t.Fatalf("valid clipboard audit rejected: %v", err)
}
for _, invalid := range []string{
strings.Replace(valid, `"reason":"rate"`, `"reason":"text"`, 1),
strings.Replace(valid, `"text_bytes":64`, `"text_bytes":65537`, 1),
strings.Replace(valid, `"reason":"rate"`, `"reason":"rate","text":"forbidden"`, 1),
} {
if _, err := protocol.DecodeGatewayClipboardAudit([]byte(invalid)); err == nil {
t.Fatalf("invalid clipboard audit accepted: %s", invalid)
}
}
}
func TestGatewayClipboardTextMeasuresDecodedUTF8Bytes(t *testing.T) {
for name, text := range map[string]string{
"ascii-boundary": strings.Repeat("a", 65536),
"utf8-boundary": strings.Repeat("é", 32768),
"escape-heavy": strings.Repeat(`"`, 32768),
} {
t.Run(name, func(t *testing.T) {
value := protocol.GatewayClipboardText{
Direction: "client_to_provider",
Text: text,
Encoding: "utf-8",
LoopToken: "abcdefghijklmnop",
}
encoded, err := protocol.EncodeGatewayClipboardText(value)
if err != nil {
t.Fatalf("EncodeGatewayClipboardText() error = %v", err)
}
decoded, err := protocol.DecodeGatewayClipboardText(encoded)
if err != nil || decoded.Text != text {
t.Fatalf("DecodeGatewayClipboardText() = %d bytes, %v", len(decoded.Text), err)
}
})
}
tooLarge := protocol.GatewayClipboardText{
Direction: "client_to_provider",
Text: strings.Repeat("a", 65537),
Encoding: "utf-8",
LoopToken: "abcdefghijklmnop",
}
if _, err := protocol.EncodeGatewayClipboardText(tooLarge); err == nil {
t.Fatal("EncodeGatewayClipboardText() accepted 65,537 decoded UTF-8 bytes")
}
}
+28 -15
View File
@@ -30,6 +30,9 @@ SECRET_PATTERNS = (
re.compile(rb"\bgh[pousr]_[A-Za-z0-9]{20,}\b"),
re.compile(rb"\bsk-[A-Za-z0-9]{20,}\b"),
)
ALLOWED_SECRET_PROPERTIES = {
("ProviderSessionWork", "client_private_key_pem"),
}
def fail(message: str) -> None:
@@ -53,19 +56,36 @@ def check_generated_provenance() -> None:
def check_manifest_schema() -> None:
schema = json.loads((ROOT / "schemas/control-v1.schema.json").read_text(encoding="utf-8"))
definitions = schema.get("$defs", {})
for name in ("ConnectionManifest", "ManifestGateway", "ManifestTunnel", "ManifestProfile", "ManifestBounds", "GrantReference"):
properties = definitions.get(name, {}).get("properties", {})
forbidden = sorted(FORBIDDEN_WIRE_FIELDS.intersection(properties))
if forbidden:
fail(f"{name} exposes forbidden wire fields: {forbidden}")
for name, definition in definitions.items():
for field in definition.get("properties", {}):
if any(forbidden in field.lower() for forbidden in FORBIDDEN_WIRE_FIELDS):
if (name, field) not in ALLOWED_SECRET_PROPERTIES:
fail(f"{name} exposes forbidden wire field {field}")
def check_proto_boundaries(path: pathlib.Path) -> None:
message = ""
depth = 0
for line in path.read_text(encoding="utf-8").splitlines():
match = re.match(r"\s*message\s+([A-Za-z0-9_]+)\s*\{", line)
if match and depth == 0:
message = match.group(1)
if any(field in line.lower() for field in FORBIDDEN_WIRE_FIELDS):
allowed = (
message == "ProviderSessionWork"
and re.fullmatch(r"\s*string\s+client_private_key_pem\s*=\s*[0-9]+;\s*", line)
)
if not allowed:
fail(f"{path.relative_to(ROOT)} exposes forbidden wire field in {message or 'file scope'}")
depth += line.count("{") - line.count("}")
if depth == 0:
message = ""
def check_text_boundaries() -> None:
paths = [
ROOT / "openapi/control-v1.yaml",
ROOT / "schemas/control-v1.schema.json",
ROOT / "proto/versevdi/control/v1/control.proto",
ROOT / "proto/versevdi/tunnel/v1/tunnel.proto",
ROOT / "frames/datagram-v1.md",
ROOT / "frames/registry.json",
ROOT / "registries/features.json",
@@ -77,14 +97,7 @@ def check_text_boundaries() -> None:
if field in text:
fail(f"{path.relative_to(ROOT)} contains forbidden wire field {field}")
generated_paths = list((ROOT / "gen").rglob("*"))
for path in generated_paths:
if not path.is_file() or path.name == "manifest.json" or path.suffix in {".pb", ".binpb"}:
continue
text = path.read_text(encoding="utf-8").lower()
for field in FORBIDDEN_WIRE_FIELDS:
if field in text:
fail(f"generated output {path.relative_to(ROOT)} contains forbidden wire field {field}")
check_proto_boundaries(ROOT / "proto/versevdi/tunnel/v1/tunnel.proto")
def check_secret_canaries() -> None:
+44 -1
View File
@@ -125,6 +125,8 @@ def go_validation(definition: dict[str, Any]) -> list[str]:
lines.append(f"\tif len(v.{field}) < {prop['minLength']} && v.{field} != \"\" {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"min_length\"}}) }}")
if "maxLength" in prop:
lines.append(f"\tif len(v.{field}) > {prop['maxLength']} {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"max_length\"}}) }}")
if "x-max-bytes" in prop:
lines.append(f"\tif len(v.{field}) > {prop['x-max-bytes']} {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"max_bytes\"}}) }}")
if "const" in prop:
lines.append(f"\tif v.{field} != \"{prop['const']}\" && v.{field} != \"\" {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"invalid_value\"}}) }}")
if "enum" in prop:
@@ -135,6 +137,8 @@ def go_validation(definition: dict[str, Any]) -> list[str]:
'\tif v.%s != "" { if parsed, err := time.Parse(time.RFC3339Nano, v.%s); err != nil || parsed.UTC().Format(time.RFC3339Nano) != v.%s { violations = append(violations, FieldViolation{Field: "%s", Code: "invalid_time"}) } }'
% (field, field, field, prop_name)
)
if prop.get("format") == "base64url":
lines.append(f"\tif v.{field} != \"\" {{ if _, err := base64.RawURLEncoding.Strict().DecodeString(v.{field}); err != nil {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"invalid_format\"}}) }} }}")
if prop.get("type") == "integer":
if "minimum" in prop:
lines.append(f"\tif v.{field} != 0 && v.{field} < {prop['minimum']} {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"minimum\"}}) }}")
@@ -167,6 +171,7 @@ def generate_go(defs: dict[str, dict[str, Any]], schema_hash: str, version: str,
"",
"import (",
"\"bytes\"",
"\"encoding/base64\"",
"\"encoding/json\"",
"\"errors\"",
"\"fmt\"",
@@ -223,7 +228,7 @@ def generate_go(defs: dict[str, dict[str, Any]], schema_hash: str, version: str,
% (prop_name, prop_name)
)
for prop_name, prop in defs[name].get("properties", {}).items():
if "x-max-bytes" in prop:
if "x-max-bytes" in prop and prop.get("type") != "string":
out.append(
'\tif raw, ok := fields["%s"]; ok && len(raw) > %d { return value, ValidationError{Violations: []FieldViolation{{Field: "%s", Code: "max_bytes"}}} }'
% (prop_name, prop["x-max-bytes"], prop_name)
@@ -322,11 +327,15 @@ def rust_validation(definition: dict[str, Any]) -> list[str]:
lines.append(f" {prefix}if !{value}.is_empty() && {value}.len() < {prop['minLength']} {{ return Err(ValidationError::new(\"{prop_name}\", \"min_length\")); }}")
if "maxLength" in prop:
lines.append(f" {prefix}if {value}.len() > {prop['maxLength']} {{ return Err(ValidationError::new(\"{prop_name}\", \"max_length\")); }}")
if "x-max-bytes" in prop:
lines.append(f" {prefix}if {value}.as_bytes().len() > {prop['x-max-bytes']} {{ return Err(ValidationError::new(\"{prop_name}\", \"max_bytes\")); }}")
if "const" in prop:
lines.append(f" {prefix}if {value} != \"{prop['const']}\" {{ return Err(ValidationError::new(\"{prop_name}\", \"invalid_value\")); }}")
if "enum" in prop:
allowed = " && ".join(f'{value} != \"{item}\"' for item in prop["enum"])
lines.append(f" {prefix}if {allowed} {{ return Err(ValidationError::new(\"{prop_name}\", \"invalid_value\")); }}")
if prop.get("format") == "base64url":
lines.append(f" {prefix}if !valid_base64_url({value}.as_str()) {{ return Err(ValidationError::new(\"{prop_name}\", \"invalid_format\")); }}")
if prop.get("type") == "integer":
if "minimum" in prop:
lines.append(f" {prefix}if {value} < {prop['minimum']} {{ return Err(ValidationError::new(\"{prop_name}\", \"minimum\")); }}")
@@ -369,6 +378,27 @@ def generate_rust(defs: dict[str, dict[str, Any]], schema_hash: str, compatibili
"pub struct ValidationError { pub field: &'static str, pub code: &'static str }",
"impl ValidationError { pub const fn new(field: &'static str, code: &'static str) -> Self { Self { field, code } } }",
"",
"fn base64url_value(value: u8) -> Option<u8> {",
" match value {",
" b'A'..=b'Z' => Some(value - b'A'),",
" b'a'..=b'z' => Some(value - b'a' + 26),",
" b'0'..=b'9' => Some(value - b'0' + 52),",
" b'-' => Some(62),",
" b'_' => Some(63),",
" _ => None,",
" }",
"}",
"fn valid_base64_url(value: &str) -> bool {",
" let bytes = value.as_bytes();",
" if bytes.is_empty() || bytes.iter().any(|byte| base64url_value(*byte).is_none()) { return false; }",
" match bytes.len() % 4 {",
" 0 => true,",
" 2 => base64url_value(*bytes.last().unwrap()).unwrap() & 0x0f == 0,",
" 3 => base64url_value(*bytes.last().unwrap()).unwrap() & 0x03 == 0,",
" _ => false,",
" }",
"}",
"",
]
for name in sorted(defs):
definition = defs[name]
@@ -451,6 +481,8 @@ def swift_validation(definition: dict[str, Any]) -> list[str]:
lines.append(f" {prefix}if !{value}.isEmpty && {value}.utf8.count < {prop['minLength']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"min_length\") }}")
if "maxLength" in prop:
lines.append(f" {prefix}if {value}.utf8.count > {prop['maxLength']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"max_length\") }}")
if "x-max-bytes" in prop:
lines.append(f" {prefix}if {value}.utf8.count > {prop['x-max-bytes']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"max_bytes\") }}")
if "const" in prop:
lines.append(f" {prefix}if {value} != \"{prop['const']}\" {{ throw ContractValidationError(field: \"{prop_name}\", code: \"invalid_value\") }}")
if "enum" in prop:
@@ -458,6 +490,8 @@ def swift_validation(definition: dict[str, Any]) -> list[str]:
lines.append(f" {prefix}if ![{allowed}].contains({value}) {{ throw ContractValidationError(field: \"{prop_name}\", code: \"invalid_value\") }}")
if prop.get("format") == "date-time":
lines.append(f" {prefix}if ISO8601DateFormatter().date(from: {value}) == nil {{ throw ContractValidationError(field: \"{prop_name}\", code: \"invalid_time\") }}")
if prop.get("format") == "base64url":
lines.append(f" {prefix}if !validBase64URL({value}) {{ throw ContractValidationError(field: \"{prop_name}\", code: \"invalid_format\") }}")
if prop.get("type") == "integer":
if "minimum" in prop:
lines.append(f" {prefix}if {value} < {prop['minimum']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"minimum\") }}")
@@ -497,6 +531,15 @@ def generate_swift(defs: dict[str, dict[str, Any]], schema_hash: str, compatibil
f'public let nMinus2WireVersion = "{compatibility["n_minus_2"]}"',
"public struct ContractValidationError: Error, Equatable { public let field: String; public let code: String }",
"private struct AnyCodingKey: CodingKey { let stringValue: String; let intValue: Int?; init?(stringValue: String) { self.stringValue = stringValue; self.intValue = nil }; init?(intValue: Int) { self.stringValue = String(intValue); self.intValue = intValue } }",
"private func validBase64URL(_ value: String) -> Bool {",
" guard !value.isEmpty, value.utf8.allSatisfy({ byte in",
" (byte >= 65 && byte <= 90) || (byte >= 97 && byte <= 122) || (byte >= 48 && byte <= 57) || byte == 45 || byte == 95",
" }) else { return false }",
" let padding = String(repeating: \"=\", count: (4 - value.utf8.count % 4) % 4)",
" let standard = value.replacingOccurrences(of: \"-\", with: \"+\").replacingOccurrences(of: \"_\", with: \"/\") + padding",
" guard let decoded = Data(base64Encoded: standard) else { return false }",
" return decoded.base64EncodedString().replacingOccurrences(of: \"+\", with: \"-\").replacingOccurrences(of: \"/\", with: \"_\").replacingOccurrences(of: \"=\", with: \"\") == value",
"}",
"",
]
for name in sorted(defs):
+168
View File
@@ -9,6 +9,7 @@ import (
"path/filepath"
"strconv"
"strings"
"unicode/utf8"
protocol "git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol/gen/go/protocol"
)
@@ -138,11 +139,178 @@ func evaluate(kind, input string) string {
return "invalid:unsupported_version"
case "datagram":
return classifyDatagram(parts["hex"])
case "gateway_input":
return classifyGatewayInput(parts["hex"])
case "gateway_feedback":
return classifyGatewayFeedback(parts["hex"])
case "gateway_clipboard":
if _, hasFile := parts["file"]; hasFile {
return "invalid:forbidden"
}
value := protocol.GatewayClipboardText{Direction: parts["direction"], Text: parts["text"], Encoding: parts["encoding"], LoopToken: parts["loop_token"]}
if value.Validate() != nil {
return "invalid:clipboard"
}
return "valid"
case "gateway_clipboard_audit":
if _, hasText := parts["text"]; hasText {
return "invalid:forbidden"
}
textBytes, err := strconv.ParseInt(parts["text_bytes"], 10, 64)
if err != nil {
return "invalid:clipboard_audit"
}
value := protocol.GatewayClipboardAudit{Version: "1", SessionID: "fixture-session", Direction: parts["direction"], Outcome: parts["outcome"], TextBytes: textBytes, Reason: parts["reason"]}
if value.Validate() != nil {
return "invalid:clipboard_audit"
}
return "valid"
default:
return "invalid:unknown_kind"
}
}
func decodeGatewayHex(encoded string) ([]byte, string) {
raw, err := hex.DecodeString(encoded)
if err != nil {
return nil, "invalid:hex"
}
return raw, ""
}
func classifyGatewayInput(encoded string) string {
raw, invalid := decodeGatewayHex(encoded)
if invalid != "" {
return invalid
}
if len(raw) < 6 {
return "invalid:truncated"
}
if string(raw[:4]) != "VGI1" {
return "invalid:magic"
}
kind, length := raw[4], int(raw[5])
if len(raw) != 6+length {
return "invalid:length"
}
body := raw[6:]
switch kind {
case 1:
if len(body) != 4 || body[0] > 1 || (body[2] == 0 && body[3] == 0) {
return "invalid:field"
}
case 2:
if len(body) != 3 {
return "invalid:length"
}
if body[0] > 1 || body[1] < 1 || body[1] > 5 {
return "invalid:field"
}
if body[2] != 0 {
return "invalid:reserved"
}
case 3:
if len(body) != 4 {
return "invalid:length"
}
case 4:
if len(body) < 1 || len(body) > 4 || !utf8.Valid(body) || utf8.RuneCount(body) != 1 {
return "invalid:utf8"
}
case 5:
if len(body) != 17 {
return "invalid:length"
}
if body[0] > 15 {
return "invalid:field"
}
if body[1] == 0 && body[2] == 0 {
for _, value := range body[3:] {
if value != 0 {
return "invalid:field"
}
}
}
default:
return "invalid:kind"
}
return "valid"
}
func classifyGatewayFeedback(encoded string) string {
raw, invalid := decodeGatewayHex(encoded)
if invalid != "" {
return invalid
}
if len(raw) < 8 {
return "invalid:truncated"
}
if string(raw[:4]) != "VGF1" {
return "invalid:magic"
}
direction, kind := raw[4], raw[5]
if len(raw) != 8+(int(raw[6])<<8)+int(raw[7]) {
return "invalid:length"
}
if direction != 0 && direction != 1 {
return "invalid:direction"
}
body := raw[8:]
if direction == 0 {
if kind >= 0x10 && kind <= 0x12 {
return "invalid:direction"
}
switch kind {
case 1:
if len(body) == 0 {
return "valid"
}
case 2:
if validFECStatus(body) {
return "valid"
}
return "invalid:field"
default:
return "invalid:type"
}
return "invalid:length"
}
if kind == 1 || kind == 2 {
return "invalid:direction"
}
switch kind {
case 0x10:
if len(body) == 4 {
return "valid"
}
return "invalid:length"
case 0x11:
if len(body) != 5 {
return "invalid:length"
}
if body[0] <= 15 {
return "valid"
}
case 0x12:
if len(body) != 1 {
return "invalid:length"
}
if body[0] <= 1 {
return "valid"
}
default:
return "invalid:type"
}
return "invalid:field"
}
func validFECStatus(body []byte) bool {
if len(body) != 21 || int(body[10])<<8|int(body[11]) == 0 || int(body[14])<<8|int(body[15]) > int(body[10])<<8|int(body[11]) || int(body[16])<<8|int(body[17]) > int(body[12])<<8|int(body[13]) || body[18] > 100 || body[20] == 0 || body[19] >= body[20] {
return false
}
return true
}
func classifyDatagram(encoded string) string {
raw, err := hex.DecodeString(encoded)
if err != nil {
+115
View File
@@ -53,10 +53,125 @@ fn evaluate(kind: &str, input: &str) -> &'static str {
}
"tunnel" => "invalid:unsupported_version",
"datagram" => classify_datagram(values.get("hex").map(String::as_str).unwrap_or_default()),
"gateway_input" => classify_gateway_input(values.get("hex").map(String::as_str).unwrap_or_default()),
"gateway_feedback" => classify_gateway_feedback(values.get("hex").map(String::as_str).unwrap_or_default()),
"gateway_clipboard" if values.contains_key("file") => "invalid:forbidden",
"gateway_clipboard" => match (
values.get("direction"),
values.get("text"),
values.get("encoding"),
values.get("loop_token"),
) {
(Some(direction), Some(text), Some(encoding), Some(token))
if GatewayClipboardText::new(
direction.clone(), text.clone(), encoding.clone(), token.clone(),
).is_ok() => "valid",
_ => "invalid:clipboard",
},
"gateway_clipboard_audit" if values.contains_key("text") => "invalid:forbidden",
"gateway_clipboard_audit"
if matches!(values.get("direction").map(String::as_str), Some("client_to_provider") | Some("provider_to_client"))
&& matches!(values.get("outcome").map(String::as_str), Some("forwarded") | Some("suppressed") | Some("rejected"))
&& matches!(values.get("reason").map(String::as_str), Some("forwarded") | Some("loop") | Some("policy") | Some("rate") | Some("provider") | Some("malformed"))
&& values.get("text_bytes").and_then(|value| value.parse::<usize>().ok()).map_or(false, |size| size <= 65536) => "valid",
"gateway_clipboard_audit" => "invalid:clipboard_audit",
_ => "invalid:unknown_kind",
}
}
fn classify_gateway_input(encoded: &str) -> &'static str {
let raw = match decode_hex(encoded) {
Some(raw) => raw,
None => return "invalid:hex",
};
if raw.len() < 6 {
return "invalid:truncated";
}
if raw[0..4] != *b"VGI1" {
return "invalid:magic";
}
let kind = raw[4];
let body = &raw[6..];
if body.len() != raw[5] as usize {
return "invalid:length";
}
match kind {
1 if body.len() == 4 && body[0] <= 1 && (body[2] != 0 || body[3] != 0) => "valid",
1 => "invalid:field",
2 if body.len() != 3 => "invalid:length",
2 if body[0] > 1 || !(1..=5).contains(&body[1]) => "invalid:field",
2 if body[2] != 0 => "invalid:reserved",
2 => "valid",
3 if body.len() == 4 => "valid",
3 => "invalid:length",
4 if (1..=4).contains(&body.len()) && std::str::from_utf8(body).ok().map_or(false, |value| value.chars().count() == 1) => "valid",
4 => "invalid:utf8",
5 if body.len() != 17 => "invalid:length",
5 if body[0] > 15 => "invalid:field",
5 if body[1] == 0 && body[2] == 0 && body[3..].iter().any(|value| *value != 0) => "invalid:field",
5 => "valid",
_ => "invalid:kind",
}
}
fn classify_gateway_feedback(encoded: &str) -> &'static str {
let raw = match decode_hex(encoded) {
Some(raw) => raw,
None => return "invalid:hex",
};
if raw.len() < 8 {
return "invalid:truncated";
}
if raw[0..4] != *b"VGF1" {
return "invalid:magic";
}
let direction = raw[4];
let kind = raw[5];
let body = &raw[8..];
if body.len() != ((raw[6] as usize) << 8 | raw[7] as usize) {
return "invalid:length";
}
if direction > 1 {
return "invalid:direction";
}
if direction == 0 {
if (0x10..=0x12).contains(&kind) {
return "invalid:direction";
}
return match kind {
1 if body.is_empty() => "valid",
1 => "invalid:length",
2 if valid_fec_status(body) => "valid",
2 => "invalid:field",
_ => "invalid:type",
};
}
if kind == 1 || kind == 2 {
return "invalid:direction";
}
match kind {
0x10 if body.len() == 4 => "valid",
0x10 => "invalid:length",
0x11 if body.len() != 5 => "invalid:length",
0x11 if body[0] <= 15 => "valid",
0x11 => "invalid:field",
0x12 if body.len() != 1 => "invalid:length",
0x12 if body[0] <= 1 => "valid",
0x12 => "invalid:field",
_ => "invalid:type",
}
}
fn valid_fec_status(body: &[u8]) -> bool {
body.len() == 21
&& ((body[10] as u16) << 8 | body[11] as u16) > 0
&& ((body[14] as u16) << 8 | body[15] as u16) <= ((body[10] as u16) << 8 | body[11] as u16)
&& ((body[16] as u16) << 8 | body[17] as u16) <= ((body[12] as u16) << 8 | body[13] as u16)
&& body[18] <= 100
&& body[20] > 0
&& body[19] < body[20]
}
fn decode_hex(input: &str) -> Option<Vec<u8>> {
if input.len() % 2 != 0 {
return None;
+89 -3
View File
@@ -30,18 +30,104 @@ func evaluate(_ kind: String, _ input: String) -> String {
if ["1", "0", "-1"].contains(values["offered"] ?? "") && values["feature"] == "control.v1" { return "valid" }
return values["feature"] == "control.v1" ? "invalid:unsupported_version" : "invalid:unsupported_feature"
case "datagram": return classifyDatagram(values["hex"] ?? "")
case "gateway_input": return classifyGatewayInput(values["hex"] ?? "")
case "gateway_feedback": return classifyGatewayFeedback(values["hex"] ?? "")
case "gateway_clipboard":
if values["file"] != nil { return "invalid:forbidden" }
guard let direction = values["direction"], let text = values["text"],
let encoding = values["encoding"], let token = values["loop_token"],
(try? GatewayClipboardText(
direction: direction, text: text, encoding: encoding, loopToken: token
)) != nil else { return "invalid:clipboard" }
return "valid"
case "gateway_clipboard_audit":
if values["text"] != nil { return "invalid:forbidden" }
guard ["client_to_provider", "provider_to_client"].contains(values["direction"] ?? ""), ["forwarded", "suppressed", "rejected"].contains(values["outcome"] ?? ""), ["forwarded", "loop", "policy", "rate", "provider", "malformed"].contains(values["reason"] ?? ""), let textBytes = Int(values["text_bytes"] ?? ""), (0...65536).contains(textBytes) else { return "invalid:clipboard_audit" }
return "valid"
default: return "invalid:unknown_kind"
}
}
func classifyDatagram(_ encoded: String) -> String {
func decodeHex(_ encoded: String) -> [UInt8]? {
let characters = Array(encoded)
guard characters.count % 2 == 0 else { return "invalid:hex" }
guard characters.count % 2 == 0 else { return nil }
var raw: [UInt8] = []
for index in stride(from: 0, to: characters.count, by: 2) {
guard let byte = UInt8(String(characters[index...index + 1]), radix: 16) else { return "invalid:hex" }
guard let byte = UInt8(String(characters[index...index + 1]), radix: 16) else { return nil }
raw.append(byte)
}
return raw
}
func classifyGatewayInput(_ encoded: String) -> String {
guard let raw = decodeHex(encoded) else { return "invalid:hex" }
guard raw.count >= 6 else { return "invalid:truncated" }
guard Array(raw[0..<4]) == Array("VGI1".utf8) else { return "invalid:magic" }
let kind = raw[4]
let body = Array(raw.dropFirst(6))
guard body.count == Int(raw[5]) else { return "invalid:length" }
switch kind {
case 1:
return body.count == 4 && body[0] <= 1 && (body[2] != 0 || body[3] != 0) ? "valid" : "invalid:field"
case 2:
guard body.count == 3 else { return "invalid:length" }
guard body[0] <= 1 && (1...5).contains(body[1]) else { return "invalid:field" }
return body[2] == 0 ? "valid" : "invalid:reserved"
case 3: return body.count == 4 ? "valid" : "invalid:length"
case 4:
guard (1...4).contains(body.count), let scalar = String(bytes: body, encoding: .utf8), scalar.unicodeScalars.count == 1 else { return "invalid:utf8" }
return "valid"
case 5:
guard body.count == 17 else { return "invalid:length" }
guard body[0] <= 15 else { return "invalid:field" }
guard body[1] != 0 || body[2] != 0 || body.dropFirst(3).allSatisfy({ $0 == 0 }) else { return "invalid:field" }
return "valid"
default: return "invalid:kind"
}
}
func classifyGatewayFeedback(_ encoded: String) -> String {
guard let raw = decodeHex(encoded) else { return "invalid:hex" }
guard raw.count >= 8 else { return "invalid:truncated" }
guard Array(raw[0..<4]) == Array("VGF1".utf8) else { return "invalid:magic" }
let direction = raw[4]
let kind = raw[5]
let body = Array(raw.dropFirst(8))
guard body.count == Int(raw[6]) * 256 + Int(raw[7]) else { return "invalid:length" }
guard direction <= 1 else { return "invalid:direction" }
if direction == 0 {
if (0x10...0x12).contains(kind) { return "invalid:direction" }
switch kind {
case 1: return body.isEmpty ? "valid" : "invalid:length"
case 2:
return validFECStatus(body) ? "valid" : "invalid:field"
default: return "invalid:type"
}
}
if kind == 1 || kind == 2 { return "invalid:direction" }
switch kind {
case 0x10: return body.count == 4 ? "valid" : "invalid:length"
case 0x11:
guard body.count == 5 else { return "invalid:length" }
return body[0] <= 15 ? "valid" : "invalid:field"
case 0x12:
guard body.count == 1 else { return "invalid:length" }
return body[0] <= 1 ? "valid" : "invalid:field"
default: return "invalid:type"
}
}
func validFECStatus(_ body: [UInt8]) -> Bool {
guard body.count == 21 else { return false }
let totalData = Int(body[10]) * 256 + Int(body[11])
let totalParity = Int(body[12]) * 256 + Int(body[13])
let receivedData = Int(body[14]) * 256 + Int(body[15])
let receivedParity = Int(body[16]) * 256 + Int(body[17])
return totalData > 0 && receivedData <= totalData && receivedParity <= totalParity && body[18] <= 100 && body[20] > 0 && body[19] < body[20]
}
func classifyDatagram(_ encoded: String) -> String {
guard let raw = decodeHex(encoded) else { return "invalid:hex" }
guard raw.count >= 21 else { return "invalid:truncated" }
guard raw[0] == 0x56 && raw[1] == 0x44 else { return "invalid:magic" }
guard raw[2] == 1 else { return "invalid:unsupported_version" }
+8 -1
View File
@@ -20,7 +20,14 @@ def main() -> int:
temp = pathlib.Path(directory)
rust_bin = temp / "rust-conformance"
swift_bin = temp / "swift-conformance"
run(["rustc", "tools/native_conformance.rs", "-O", "-o", str(rust_bin)])
rust_source = temp / "main.rs"
rust_source.write_text(
(ROOT / "gen/rust/protocol.rs").read_text(encoding="utf-8")
+ "\n"
+ (ROOT / "tools/native_conformance.rs").read_text(encoding="utf-8"),
encoding="utf-8",
)
run(["rustc", str(rust_source), "-O", "-o", str(rust_bin)])
run([str(rust_bin)])
main_source = temp / "main.swift"
main_source.write_text((ROOT / "tools/native_conformance.swift").read_text(encoding="utf-8"), encoding="utf-8")
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""Focused contract-boundary regressions for check_scope.py."""
from __future__ import annotations
import json
import pathlib
import tempfile
import check_scope
TEXT_PATHS = (
"openapi/control-v1.yaml",
"proto/versevdi/control/v1/control.proto",
"proto/versevdi/tunnel/v1/tunnel.proto",
"frames/datagram-v1.md",
"frames/registry.json",
"registries/features.json",
"registries/datagrams.json",
)
PROVIDER_WORK_PROTO = """
message ProviderSessionWork {
string client_private_key_pem = 15;
}
"""
def schema_with_private_key(owner: str) -> dict[str, object]:
definitions = {
name: {"type": "object", "properties": {}}
for name in (
"ConnectionManifest",
"ManifestGateway",
"ManifestTunnel",
"ManifestProfile",
"ManifestBounds",
"GrantReference",
"ProviderSessionWork",
)
}
definitions[owner]["properties"] = {
"client_private_key_pem": {"type": "string"},
}
return {"$defs": definitions}
def run_scope(schema: dict[str, object], overrides: dict[str, str] | None = None) -> None:
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
schema_path = root / "schemas/control-v1.schema.json"
schema_path.parent.mkdir(parents=True)
schema_path.write_text(json.dumps(schema), encoding="utf-8")
for relative in TEXT_PATHS:
path = root / relative
path.parent.mkdir(parents=True, exist_ok=True)
default = PROVIDER_WORK_PROTO if relative == "proto/versevdi/tunnel/v1/tunnel.proto" else ""
path.write_text((overrides or {}).get(relative, default), encoding="utf-8")
(root / "gen").mkdir()
original_root = check_scope.ROOT
check_scope.ROOT = root
try:
check_scope.check_manifest_schema()
check_scope.check_text_boundaries()
finally:
check_scope.ROOT = original_root
def expect_rejected(schema: dict[str, object], overrides: dict[str, str] | None = None) -> None:
try:
run_scope(schema, overrides)
except ValueError:
return
raise AssertionError("client-visible private-key material was accepted")
def main() -> None:
run_scope(schema_with_private_key("ProviderSessionWork"))
expect_rejected(schema_with_private_key("ConnectionManifest"))
expect_rejected(schema_with_private_key("ManifestProfile"))
expect_rejected(
schema_with_private_key("ProviderSessionWork"),
{
"proto/versevdi/tunnel/v1/tunnel.proto": """
message ConnectionManifest {
string client_private_key_pem = 1;
}
"""
},
)
expect_rejected(
schema_with_private_key("ProviderSessionWork"),
{"frames/datagram-v1.md": "client_private_key_pem"},
)
print("Protocol contract-aware scope regression passed")
if __name__ == "__main__":
main()
+32
View File
@@ -85,6 +85,25 @@ do {
)
fatalError("invalid allocation bounds were accepted")
} catch { }
for text in [
String(repeating: "a", count: 65536),
String(repeating: "é", count: 32768),
String(repeating: "\\\"", count: 32768),
] {
let clipboard = try GatewayClipboardText(
direction: "client_to_provider", text: text, encoding: "utf-8",
loopToken: "abcdefghijklmnop"
)
let decoded = try GatewayClipboardText.decodeJSON(clipboard.encodeJSON())
guard decoded.text == text else { fatalError("clipboard text changed during round-trip") }
}
do {
_ = try GatewayClipboardText(
direction: "client_to_provider", text: String(repeating: "a", count: 65537),
encoding: "utf-8", loopToken: "abcdefghijklmnop"
)
fatalError("oversized clipboard text was accepted")
} catch { }
""",
encoding="utf-8",
)
@@ -130,6 +149,19 @@ fn main() {
assert!(AllocationPolicy::new(
100, 50, 25, "standard".into(), "audience".into(), "verse".into(), 1, 60, 300,
).is_err());
for text in [
"a".repeat(65536),
"é".repeat(32768),
"\\\"".repeat(32768),
] {
assert!(GatewayClipboardText::new(
"client_to_provider".into(), text, "utf-8".into(), "abcdefghijklmnop".into(),
).is_ok());
}
assert!(GatewayClipboardText::new(
"client_to_provider".into(), "a".repeat(65537), "utf-8".into(),
"abcdefghijklmnop".into(),
).is_err());
}
"""
)
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""Validate fixed-byte Phase 3C gateway input and feedback envelopes."""
from __future__ import annotations
import binascii
import pathlib
ROOT = pathlib.Path(__file__).resolve().parents[1]
def classify_input(raw: bytes) -> str:
if len(raw) < 6:
return "invalid:truncated"
if raw[:4] != b"VGI1":
return "invalid:magic"
kind, length = raw[4], raw[5]
if len(raw) != 6 + length:
return "invalid:length"
body = raw[6:]
if kind == 1:
return "valid" if len(body) == 4 and body[0] <= 1 and body[2:4] != b"\0\0" else "invalid:field"
if kind == 2:
return "valid" if len(body) == 3 and body[0] <= 1 and 1 <= body[1] <= 5 and body[2] == 0 else "invalid:reserved"
if kind == 3:
return "valid" if len(body) == 4 else "invalid:length"
if kind == 4:
try:
decoded = body.decode("utf-8")
except UnicodeDecodeError:
return "invalid:utf8"
return "valid" if 1 <= len(body) <= 4 and len(decoded) == 1 else "invalid:utf8"
if kind == 5:
if len(body) != 17:
return "invalid:length"
if body[0] > 15:
return "invalid:field"
active_mask = int.from_bytes(body[1:3], "big")
return "valid" if active_mask or not any(body[3:]) else "invalid:field"
return "invalid:kind"
def classify_feedback(raw: bytes) -> str:
if len(raw) < 8:
return "invalid:truncated"
if raw[:4] != b"VGF1":
return "invalid:magic"
direction, kind = raw[4], raw[5]
length = int.from_bytes(raw[6:8], "big")
if len(raw) != 8 + length:
return "invalid:length"
if direction not in (0, 1):
return "invalid:direction"
body = raw[8:]
if direction == 0:
if kind in (0x10, 0x11, 0x12):
return "invalid:direction"
if kind == 1:
return "valid" if not body else "invalid:length"
if kind == 2:
return "valid" if valid_fec_status(body) else "invalid:field"
return "invalid:type"
if kind in (1, 2):
return "invalid:direction"
if kind == 0x10:
return "valid" if len(body) == 4 else "invalid:length"
if kind == 0x11:
return "valid" if len(body) == 5 and body[0] <= 15 else "invalid:field"
if kind == 0x12:
return "valid" if len(body) == 1 and body[0] <= 1 else "invalid:field"
return "invalid:type"
def valid_fec_status(body: bytes) -> bool:
return len(body) == 21 and int.from_bytes(body[10:12], "big") > 0 and int.from_bytes(body[14:16], "big") <= int.from_bytes(body[10:12], "big") and int.from_bytes(body[16:18], "big") <= int.from_bytes(body[12:14], "big") and body[18] <= 100 and body[20] > 0 and body[19] < body[20]
def main() -> None:
lines = (ROOT / "fixtures/conformance/gateway-input-feedback-v1.tsv").read_text(encoding="utf-8").splitlines()
assert lines[0] == "id\tversion\tkind\tinput\texpected"
for line in lines[1:]:
identifier, version, kind, input_value, expected = line.split("\t")
assert version == "1"
try:
raw = binascii.unhexlify(input_value.removeprefix("hex="))
except binascii.Error:
actual = "invalid:hex"
else:
actual = classify_input(raw) if kind == "gateway_input" else classify_feedback(raw)
assert actual == expected, f"{identifier}: {actual} != {expected}"
print("Gateway input/feedback envelope validation passed")
if __name__ == "__main__":
main()