Compare commits

..
Author SHA1 Message Date
sechmachine afbcea62f9 Protocol: split client session authority
Verify Protocol / verify (push) Successful in 1m2s
Verify Protocol / module (push) Successful in 1m45s
2026-08-12 11:50:41 +07:00
sechmachine b6a4f773e4 Protocol: freeze device proof and browser CSRF contracts
Verify Protocol / module (push) Successful in 1m12s
Verify Protocol / verify (push) Successful in 22s
2026-08-11 21:21:28 +07:00
sechmachine afcd5d99db feat(protocol): define native session credentials
Verify Protocol / module (push) Successful in 1m13s
Verify Protocol / verify (push) Successful in 35s
2026-08-11 18:38:19 +07:00
sechmachine 79d9e49497 docs(openspec): archive phase 3d display input 2026-08-10 23:09:58 +07:00
sechmachine 408d4f9cc3 feat(protocol): negotiate display and native input
Verify Protocol / module (push) Successful in 1m17s
Verify Protocol / verify (push) Successful in 55s
2026-08-10 23:07:04 +07:00
sechmachine 346bf5fe4d docs(openspec): archive complete frame transport 2026-08-10 08:54:36 +07:00
sechmachine 7c89ef5bf5 docs(openspec): record RC10 distribution
Verify Protocol / module (push) Successful in 2m10s
Verify Protocol / verify (push) Canceled after 0s
2026-08-09 13:58:00 +07:00
sechmachine c554cac00d feat(protocol): carry complete encoded media frames
Verify Protocol / verify (push) Canceled after 0s
Verify Protocol / module (push) Successful in 53s
2026-07-30 22:23:22 +07:00
sechmachine d1d00d6472 ci: pin Protocol verification actions
Verify Protocol / module (push) Successful in 26s
Verify Protocol / verify (push) Canceled after 0s
2026-07-30 18:33:35 +07:00
sechmachine 03e14a9ae3 docs(openspec): archive terminal receipt repair
Verify Protocol / module (push) Successful in 20s
Verify Protocol / verify (push) Canceled after 0s
2026-07-30 18:31:28 +07:00
sechmachine 995f63a27f docs(openspec): record gateway rc9 publication
Verify Protocol / module (push) Successful in 1m46s
Verify Protocol / verify (push) Canceled after 0s
2026-07-30 14:24:08 +07:00
sechmachine 30bb1a2fa3 fix(protocol): align terminal receipt conformance
Verify Protocol / verify (push) Canceled after 0s
Verify Protocol / module (push) Successful in 1m50s
2026-07-30 14:20:48 +07:00
sechmachine 021ecf425f docs(protocol): archive terminal receipt contract
Verify Protocol / module (push) Successful in 1m43s
Verify Protocol / verify (push) Canceled after 0s
2026-07-30 10:50:57 +07:00
sechmachine ec15279b42 spec(protocol): define terminal event receipt
Verify Protocol / verify (push) Canceled after 0s
Verify Protocol / module (push) Successful in 2m23s
2026-07-30 10:48:08 +07:00
sechmachine 37c041e13e style(openspec): normalize canonical spec eof 2026-07-30 07:40:27 +07:00
sechmachine 2e92fae27f docs(openspec): archive phase3c gateway contracts 2026-07-30 07:38:39 +07:00
sechmachine 534bb1031b docs(protocol): record RC7 consumer resolution 2026-07-30 05:06:19 +07:00
sechmachine e58f1c7c48 feat(protocol): negotiate registered gateway profiles
Verify Protocol / verify (push) Canceled after 0s
Verify Protocol / module (push) Successful in 2m29s
2026-07-30 04:47:52 +07:00
sechmachine d26f8b60f8 feat(protocol): carry stream policy and gateway telemetry 2026-07-30 01:45:48 +07:00
sechmachine 59741761ce docs(openspec): archive gateway input feedback contract 2026-07-29 23:02:19 +07:00
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
sechmachine 36f6edffca feat(protocol): include Apollo client identity in session work
Verify Protocol / verify (push) Canceled after 0s
Verify Protocol / module (push) Successful in 2m35s
2026-07-29 11:28:25 +07:00
sechmachine 357e5e0dbc feat(protocol): add gateway provider session work
Verify Protocol / verify (push) Canceled after 0s
Verify Protocol / module (push) Successful in 2m30s
2026-07-29 11:10:32 +07:00
sechmachine 4a2772c053 feat(protocol): bind tunnel admission to device proof
Verify Protocol / verify (push) Canceled after 0s
Verify Protocol / module (push) Successful in 2m11s
2026-07-29 10:32:08 +07:00
sechmachine be709724ba feat(protocol): validate generated gateway contracts
Verify Protocol / verify (push) Canceled after 0s
Verify Protocol / module (push) Successful in 2m14s
2026-07-29 10:03:37 +07:00
sechmachine 7c145001c0 docs(protocol): archive gateway contract 2026-07-29 07:00:02 +07:00
sechmachine 28a9aace24 docs(protocol): complete gateway candidate task 2026-07-29 06:43:34 +07:00
sechmachine b5558133e3 feat(protocol): add gateway transport authority contracts 2026-07-29 05:59:46 +07:00
sechmachine b2c24b3fa3 chore: update gitignore
Verify Protocol / module (push) Successful in 1m56s
Verify Protocol / verify (push) Canceled after 0s
2026-07-22 02:13:58 +07:00
105 changed files with 11869 additions and 528 deletions
+3 -3
View File
@@ -20,8 +20,8 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-go@v7 - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with: with:
go-version: "1.26.5" go-version: "1.26.5"
cache: true cache: true
@@ -44,7 +44,7 @@ jobs:
runs-on: macos-26 runs-on: macos-26
timeout-minutes: 30 timeout-minutes: 30
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Assert pinned toolchain - name: Assert pinned toolchain
shell: bash shell: bash
run: | run: |
+3
View File
@@ -21,7 +21,10 @@
# Go workspace file # Go workspace file
go.work go.work
go.work.sum go.work.sum
/.build
# env file # env file
.env .env
# IDE files
/.idea
+11 -2
View File
@@ -1,4 +1,4 @@
.PHONY: verify generate proto-lint proto-breaking source-verify scope-verify conformance frame-verify go-test binding-compile clean-generated .PHONY: verify generate proto-lint proto-breaking source-verify scope-verify ci-verify conformance frame-verify go-test binding-compile strict-contracts clean-generated
PYTHON ?= python3 PYTHON ?= python3
PROTOC ?= protoc PROTOC ?= protoc
@@ -20,14 +20,22 @@ source-verify:
$(PYTHON) -B tools/fixture_digest.py $(PYTHON) -B tools/fixture_digest.py
scope-verify: scope-verify:
$(PYTHON) -B tools/test_check_scope.py
$(PYTHON) -B tools/check_scope.py $(PYTHON) -B tools/check_scope.py
ci-verify:
$(PYTHON) -B tools/check_ci_actions.py
go-test: go-test:
go test ./gen/go/... ./tests/go go test ./gen/go/... ./tests/go
binding-compile: binding-compile:
rustc --crate-type lib gen/rust/protocol.rs -o /tmp/versevdi-protocol-generated.rlib rustc --crate-type lib gen/rust/protocol.rs -o /tmp/versevdi-protocol-generated.rlib
swiftc -typecheck gen/swift/Protocol.swift swiftc -typecheck gen/swift/Protocol.swift
swift build
strict-contracts:
$(PYTHON) -B tools/test_generated_contracts.py
conformance: conformance:
$(PYTHON) -B tools/fixture_digest.py $(PYTHON) -B tools/fixture_digest.py
@@ -36,8 +44,9 @@ conformance:
frame-verify: frame-verify:
$(PYTHON) tools/validate_frames.py $(PYTHON) tools/validate_frames.py
$(PYTHON) tools/validate_gateway_envelopes.py
clean-generated: clean-generated:
$(PYTHON) tools/generate.py --check $(PYTHON) tools/generate.py --check
verify: generate proto-lint proto-breaking source-verify scope-verify go-test binding-compile conformance frame-verify clean-generated verify: generate proto-lint proto-breaking source-verify scope-verify ci-verify go-test binding-compile strict-contracts conformance frame-verify clean-generated
+13
View File
@@ -0,0 +1,13 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "VerseVDIProtocol",
platforms: [.macOS(.v14)],
products: [
.library(name: "VerseVDIProtocol", targets: ["VerseVDIProtocol"]),
],
targets: [
.target(name: "VerseVDIProtocol", path: "gen/swift", sources: ["Protocol.swift"]),
]
)
+4 -4
View File
@@ -1,10 +1,10 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"protocol": "versevdi-control", "protocol": "versevdi-control",
"current": "1", "current": "2",
"n_minus_1": "0", "n_minus_1": "1",
"n_minus_2": "-1", "n_minus_2": "0",
"unsupported": ["-2", "2"], "unsupported": ["-1", "3"],
"feature_registry": "registries/features.json", "feature_registry": "registries/features.json",
"datagram_registry": "registries/datagrams.json" "datagram_registry": "registries/datagrams.json"
} }
+26 -6
View File
@@ -1,11 +1,31 @@
id version kind input expected id version kind input expected
version-current 1 version 1 valid version-current 2 version 2 valid
version-n-minus-1 0 version 0 valid version-n-minus-1 1 version 1 valid
version-n-minus-2 -1 version -1 valid version-n-minus-2 0 version 0 valid
version-unsupported 2 version 2 invalid:unsupported_version version-unsupported 3 version 3 invalid:unsupported_version
page-valid 1 page limit=20;cursor=opaque valid page-valid 1 page limit=20;cursor=opaque valid
page-limit-high 1 page limit=101 invalid:invalid_limit page-limit-high 1 page limit=101 invalid:invalid_limit
manifest-valid 1 manifest version=1;gateway_id=g-1;grant=opaque-one-time-grant-value-with-at-least-43-bytes;audience=versevdi-gateway;purpose=launch;protocol=verse-gateway-v1;expires_at=2099-01-01T00:00:00Z valid manifest-valid 1 manifest version=1;gateway_id=g-1;public_identity=gateway.control.test;grant=opaque-one-time-grant-value-with-at-least-43-bytes;audience=versevdi-gateway;purpose=launch;protocol=verse-gateway-v1;expires_at=2099-01-01T00:00:00Z valid
manifest-provider-field 1 manifest gateway_id=g-1;grant=g-1;audience=versevdi-gateway;purpose=launch;provider_url=https://provider.invalid invalid:forbidden_field manifest-missing-public-identity 1 manifest version=1;gateway_id=g-1;grant=opaque-one-time-grant-value-with-at-least-43-bytes;audience=versevdi-gateway;purpose=launch;protocol=verse-gateway-v1;expires_at=2099-01-01T00:00:00Z invalid:invalid_manifest
manifest-provider-field 1 manifest gateway_id=g-1;public_identity=gateway.control.test;grant=g-1;audience=versevdi-gateway;purpose=launch;provider_url=https://provider.invalid invalid:forbidden_field
clipboard-text-valid 1 clipboard encoding=utf-8;text=hello%20world valid clipboard-text-valid 1 clipboard encoding=utf-8;text=hello%20world valid
clipboard-file 1 clipboard encoding=octet-stream;file=/tmp/a invalid:unsupported_clipboard clipboard-file 1 clipboard encoding=octet-stream;file=/tmp/a invalid:unsupported_clipboard
session-request-policy-free 2 session_request client_device_id=device-1;device_key_id=key-1;pool_id=pool-1;idempotency_key=request-1 valid
session-request-wire-v1-shape 1 session_request client_device_id=device-1;device_key_id=key-1;pool_id=pool-1;idempotency_key=request-1;policy_snapshot=client-owned invalid:unsupported_version
session-request-client-policy 2 session_request client_device_id=device-1;device_key_id=key-1;pool_id=pool-1;idempotency_key=request-1;policy_snapshot=client-owned invalid:forbidden_field
authenticated-browser-session 2 browser_authenticated_session username=alice;provider=local;roles=user;role=user valid
authenticated-browser-native-identity 2 browser_authenticated_session username=alice;provider=local;roles=user;role=user;client_device_id=device-1;device_key_id=key-1 invalid:forbidden_field
authenticated-browser-empty-role 2 browser_authenticated_session username=alice;provider=local;roles=;role=user invalid:invalid_session
authenticated-browser-long-role 2 browser_authenticated_session username=alice;provider=local;roles=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;role=user invalid:invalid_session
authenticated-browser-role-64-utf8-bytes 2 browser_authenticated_session username=alice;provider=local;roles=éééééééééééééééééééééééééééééééé;role=user valid
authenticated-browser-role-66-utf8-bytes 2 browser_authenticated_session username=alice;provider=local;roles=ééééééééééééééééééééééééééééééééé;role=user invalid:invalid_session
authenticated-native-session 2 native_authenticated_session username=alice;provider=local;roles=user;role=user;client_device_id=device-1;device_key_id=key-1 valid
authenticated-native-missing-identity 2 native_authenticated_session username=alice;provider=local;roles=user;role=user invalid:required
authenticated-partial-native-session 2 native_authenticated_session username=alice;provider=local;roles=user;role=user;client_device_id=device-1 invalid:required
authenticated-native-empty-role 2 native_authenticated_session username=alice;provider=local;roles=;role=user;client_device_id=device-1;device_key_id=key-1 invalid:invalid_session
authenticated-native-long-role 2 native_authenticated_session username=alice;provider=local;roles=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;role=user;client_device_id=device-1;device_key_id=key-1 invalid:invalid_session
authenticated-native-role-64-utf8-bytes 2 native_authenticated_session username=alice;provider=local;roles=éééééééééééééééééééééééééééééééé;role=user;client_device_id=device-1;device_key_id=key-1 valid
authenticated-native-role-66-utf8-bytes 2 native_authenticated_session username=alice;provider=local;roles=ééééééééééééééééééééééééééééééééé;role=user;client_device_id=device-1;device_key_id=key-1 invalid:invalid_session
native-tunnel-credential 2 native_tunnel_credential client_device_id=device-1;device_key_id=key-1;certificate_chain_pem=certificate;trust_bundle_pem=trust;expires_at=2099-01-01T00:00:00Z valid
native-tunnel-credential-offset-time 2 native_tunnel_credential client_device_id=device-1;device_key_id=key-1;certificate_chain_pem=certificate;trust_bundle_pem=trust;expires_at=2099-01-01T00:00:00+00:00 invalid:invalid_credential
native-tunnel-credential-noncanonical-fraction 2 native_tunnel_credential client_device_id=device-1;device_key_id=key-1;certificate_chain_pem=certificate;trust_bundle_pem=trust;expires_at=2099-01-01T00:00:00.100Z invalid:invalid_credential
1 id version kind input expected
2 version-current 1 2 version 1 2 valid
3 version-n-minus-1 0 1 version 0 1 valid
4 version-n-minus-2 -1 0 version -1 0 valid
5 version-unsupported 2 3 version 2 3 invalid:unsupported_version
6 page-valid 1 page limit=20;cursor=opaque valid
7 page-limit-high 1 page limit=101 invalid:invalid_limit
8 manifest-valid 1 manifest version=1;gateway_id=g-1;grant=opaque-one-time-grant-value-with-at-least-43-bytes;audience=versevdi-gateway;purpose=launch;protocol=verse-gateway-v1;expires_at=2099-01-01T00:00:00Z version=1;gateway_id=g-1;public_identity=gateway.control.test;grant=opaque-one-time-grant-value-with-at-least-43-bytes;audience=versevdi-gateway;purpose=launch;protocol=verse-gateway-v1;expires_at=2099-01-01T00:00:00Z valid
9 manifest-provider-field manifest-missing-public-identity 1 manifest gateway_id=g-1;grant=g-1;audience=versevdi-gateway;purpose=launch;provider_url=https://provider.invalid version=1;gateway_id=g-1;grant=opaque-one-time-grant-value-with-at-least-43-bytes;audience=versevdi-gateway;purpose=launch;protocol=verse-gateway-v1;expires_at=2099-01-01T00:00:00Z invalid:forbidden_field invalid:invalid_manifest
10 manifest-provider-field 1 manifest gateway_id=g-1;public_identity=gateway.control.test;grant=g-1;audience=versevdi-gateway;purpose=launch;provider_url=https://provider.invalid invalid:forbidden_field
11 clipboard-text-valid 1 clipboard encoding=utf-8;text=hello%20world valid
12 clipboard-file 1 clipboard encoding=octet-stream;file=/tmp/a invalid:unsupported_clipboard
13 session-request-policy-free 2 session_request client_device_id=device-1;device_key_id=key-1;pool_id=pool-1;idempotency_key=request-1 valid
14 session-request-wire-v1-shape 1 session_request client_device_id=device-1;device_key_id=key-1;pool_id=pool-1;idempotency_key=request-1;policy_snapshot=client-owned invalid:unsupported_version
15 session-request-client-policy 2 session_request client_device_id=device-1;device_key_id=key-1;pool_id=pool-1;idempotency_key=request-1;policy_snapshot=client-owned invalid:forbidden_field
16 authenticated-browser-session 2 browser_authenticated_session username=alice;provider=local;roles=user;role=user valid
17 authenticated-browser-native-identity 2 browser_authenticated_session username=alice;provider=local;roles=user;role=user;client_device_id=device-1;device_key_id=key-1 invalid:forbidden_field
18 authenticated-browser-empty-role 2 browser_authenticated_session username=alice;provider=local;roles=;role=user invalid:invalid_session
19 authenticated-browser-long-role 2 browser_authenticated_session username=alice;provider=local;roles=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;role=user invalid:invalid_session
20 authenticated-browser-role-64-utf8-bytes 2 browser_authenticated_session username=alice;provider=local;roles=éééééééééééééééééééééééééééééééé;role=user valid
21 authenticated-browser-role-66-utf8-bytes 2 browser_authenticated_session username=alice;provider=local;roles=ééééééééééééééééééééééééééééééééé;role=user invalid:invalid_session
22 authenticated-native-session 2 native_authenticated_session username=alice;provider=local;roles=user;role=user;client_device_id=device-1;device_key_id=key-1 valid
23 authenticated-native-missing-identity 2 native_authenticated_session username=alice;provider=local;roles=user;role=user invalid:required
24 authenticated-partial-native-session 2 native_authenticated_session username=alice;provider=local;roles=user;role=user;client_device_id=device-1 invalid:required
25 authenticated-native-empty-role 2 native_authenticated_session username=alice;provider=local;roles=;role=user;client_device_id=device-1;device_key_id=key-1 invalid:invalid_session
26 authenticated-native-long-role 2 native_authenticated_session username=alice;provider=local;roles=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;role=user;client_device_id=device-1;device_key_id=key-1 invalid:invalid_session
27 authenticated-native-role-64-utf8-bytes 2 native_authenticated_session username=alice;provider=local;roles=éééééééééééééééééééééééééééééééé;role=user;client_device_id=device-1;device_key_id=key-1 valid
28 authenticated-native-role-66-utf8-bytes 2 native_authenticated_session username=alice;provider=local;roles=ééééééééééééééééééééééééééééééééé;role=user;client_device_id=device-1;device_key_id=key-1 invalid:invalid_session
29 native-tunnel-credential 2 native_tunnel_credential client_device_id=device-1;device_key_id=key-1;certificate_chain_pem=certificate;trust_bundle_pem=trust;expires_at=2099-01-01T00:00:00Z valid
30 native-tunnel-credential-offset-time 2 native_tunnel_credential client_device_id=device-1;device_key_id=key-1;certificate_chain_pem=certificate;trust_bundle_pem=trust;expires_at=2099-01-01T00:00:00+00:00 invalid:invalid_credential
31 native-tunnel-credential-noncanonical-fraction 2 native_tunnel_credential client_device_id=device-1;device_key_id=key-1;certificate_chain_pem=certificate;trust_bundle_pem=trust;expires_at=2099-01-01T00:00:00.100Z invalid:invalid_credential
+3 -1
View File
@@ -1,6 +1,8 @@
id version kind input expected id version kind input expected
valid-empty-control 1 datagram hex=564401010000000000000000000000000000010000 valid valid-empty-control 1 datagram hex=564401010000000000000000000000000000010000 valid
invalid-short 1 datagram hex=564401 invalid:truncated invalid-short 1 datagram hex=564401 invalid:truncated
invalid-version 1 datagram hex=564402010000000000000000000000000000010000 invalid:unsupported_version invalid-version 1 datagram hex=564403010000000000000000000000000000010000 invalid:unsupported_version
invalid-channel 1 datagram hex=564401990000000000000000000000000000010000 invalid:unknown_channel invalid-channel 1 datagram hex=564401990000000000000000000000000000010000 invalid:unknown_channel
invalid-length 1 datagram hex=564401010000000000000000000000000000010001 invalid:length_mismatch invalid-length 1 datagram hex=564401010000000000000000000000000000010001 invalid:length_mismatch
valid-video-empty 1 datagram hex=5644010a0000000000000000000000000000010000 valid
invalid-media-channel 1 datagram hex=5644010d0000000000000000000000000000010000 invalid:unknown_channel
1 id version kind input expected
2 valid-empty-control 1 datagram hex=564401010000000000000000000000000000010000 valid
3 invalid-short 1 datagram hex=564401 invalid:truncated
4 invalid-version 1 datagram hex=564402010000000000000000000000000000010000 hex=564403010000000000000000000000000000010000 invalid:unsupported_version
5 invalid-channel 1 datagram hex=564401990000000000000000000000000000010000 invalid:unknown_channel
6 invalid-length 1 datagram hex=564401010000000000000000000000000000010001 invalid:length_mismatch
7 valid-video-empty 1 datagram hex=5644010a0000000000000000000000000000010000 valid
8 invalid-media-channel 1 datagram hex=5644010d0000000000000000000000000000010000 invalid:unknown_channel
+10
View File
@@ -0,0 +1,10 @@
id version kind input expected
v2-valid-video-single 2 datagram hex=5644020a00000000010000000000000002000000010003010203 valid
v2-valid-video-last-fragment 2 datagram hex=5644020a00000000010000000000000002037a037b0000 valid
v2-invalid-short 2 datagram hex=564402 invalid:truncated
v2-invalid-version 2 datagram hex=5644030a00000000010000000000000002000000010000 invalid:unsupported_version
v2-invalid-channel 2 datagram hex=5644020d00000000010000000000000002000000010000 invalid:unknown_channel
v2-invalid-fragment-zero 2 datagram hex=5644020a00000000010000000000000002000000000000 invalid:fragment
v2-invalid-fragment-index 2 datagram hex=5644020a00000000010000000000000002000100010000 invalid:fragment
v2-invalid-fragment-count-limit 2 datagram hex=5644020a000000000100000000000000020000037c0000 invalid:fragment_limit
v2-invalid-length 2 datagram hex=5644020a00000000010000000000000002000000010001 invalid:length_mismatch
1 id version kind input expected
2 v2-valid-video-single 2 datagram hex=5644020a00000000010000000000000002000000010003010203 valid
3 v2-valid-video-last-fragment 2 datagram hex=5644020a00000000010000000000000002037a037b0000 valid
4 v2-invalid-short 2 datagram hex=564402 invalid:truncated
5 v2-invalid-version 2 datagram hex=5644030a00000000010000000000000002000000010000 invalid:unsupported_version
6 v2-invalid-channel 2 datagram hex=5644020d00000000010000000000000002000000010000 invalid:unknown_channel
7 v2-invalid-fragment-zero 2 datagram hex=5644020a00000000010000000000000002000000000000 invalid:fragment
8 v2-invalid-fragment-index 2 datagram hex=5644020a00000000010000000000000002000100010000 invalid:fragment
9 v2-invalid-fragment-count-limit 2 datagram hex=5644020a000000000100000000000000020000037c0000 invalid:fragment_limit
10 v2-invalid-length 2 datagram hex=5644020a00000000010000000000000002000000010001 invalid:length_mismatch
+2
View File
@@ -0,0 +1,2 @@
id version kind input expected
device-proof-canonical 1 device_proof_transcript server_id=00112233445566778899aabbccddeeff;principal_id=102132435465768798a9bacbdcedfe0f;device_id=ffeeddccbbaa99887766554433221100;challenge=000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f;expiry_unix_ms=1700000000123 76657273657664692d6465766963652d70726f6f662d763100112233445566778899aabbccddeeff102132435465768798a9bacbdcedfe0fffeeddccbbaa99887766554433221100000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f0000018bcfe5687b
1 id version kind input expected
2 device-proof-canonical 1 device_proof_transcript server_id=00112233445566778899aabbccddeeff;principal_id=102132435465768798a9bacbdcedfe0f;device_id=ffeeddccbbaa99887766554433221100;challenge=000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f;expiry_unix_ms=1700000000123 76657273657664692d6465766963652d70726f6f662d763100112233445566778899aabbccddeeff102132435465768798a9bacbdcedfe0fffeeddccbbaa99887766554433221100000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f0000018bcfe5687b
@@ -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,34 @@
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-absolute-mouse 1 gateway_input hex=56474931060804d202370a0005a0 valid
valid-scroll 1 gateway_input hex=564749310704ff880078 valid
valid-idr 1 gateway_feedback hex=5647463100010000 valid
valid-fec 1 gateway_feedback hex=56474631000200150000002a000500030002000a000200080002140001 valid
valid-terminal-receipt 1 gateway_feedback hex=5647463100030000 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-absolute-zero-viewport 1 gateway_input hex=56474931060800000000000005a0 invalid:field
invalid-absolute-x-out-of-range 1 gateway_input hex=5647493106080a0000000a0005a0 invalid:field
invalid-absolute-y-out-of-range 1 gateway_input hex=564749310608000005a00a0005a0 invalid:field
invalid-absolute-length 1 gateway_input hex=56474931060700000000010001 invalid:length
invalid-scroll-length 1 gateway_input hex=5647493107020000 invalid:length
invalid-feedback-direction 1 gateway_feedback hex=5647463101020000 invalid:direction
invalid-terminal-receipt-direction 1 gateway_feedback hex=5647463101030000 invalid:direction
invalid-terminal-receipt-body 1 gateway_feedback hex=5647463100030001ff invalid:length
invalid-terminal-receipt-truncated 1 gateway_feedback hex=56474631000300 invalid:truncated
invalid-terminal-receipt-length 1 gateway_feedback hex=5647463100030001 invalid:length
invalid-feedback-type 1 gateway_feedback hex=5647463100040000 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-absolute-mouse 1 gateway_input hex=56474931060804d202370a0005a0 valid
11 valid-scroll 1 gateway_input hex=564749310704ff880078 valid
12 valid-idr 1 gateway_feedback hex=5647463100010000 valid
13 valid-fec 1 gateway_feedback hex=56474631000200150000002a000500030002000a000200080002140001 valid
14 valid-terminal-receipt 1 gateway_feedback hex=5647463100030000 valid
15 valid-termination 1 gateway_feedback hex=564746310110000400000001 valid
16 valid-rumble 1 gateway_feedback hex=56474631011100050112345678 valid
17 valid-hdr 1 gateway_feedback hex=564746310112000101 valid
18 invalid-input-magic 1 gateway_input hex=494e503101040102001e invalid:magic
19 invalid-input-kind 1 gateway_input hex=564749317f00 invalid:kind
20 invalid-input-reserved 1 gateway_input hex=564749310203010101 invalid:reserved
21 invalid-input-utf8 1 gateway_input hex=564749310402c328 invalid:utf8
22 invalid-input-length 1 gateway_input hex=564749310104010200 invalid:length
23 invalid-absolute-zero-viewport 1 gateway_input hex=56474931060800000000000005a0 invalid:field
24 invalid-absolute-x-out-of-range 1 gateway_input hex=5647493106080a0000000a0005a0 invalid:field
25 invalid-absolute-y-out-of-range 1 gateway_input hex=564749310608000005a00a0005a0 invalid:field
26 invalid-absolute-length 1 gateway_input hex=56474931060700000000010001 invalid:length
27 invalid-scroll-length 1 gateway_input hex=5647493107020000 invalid:length
28 invalid-feedback-direction 1 gateway_feedback hex=5647463101020000 invalid:direction
29 invalid-terminal-receipt-direction 1 gateway_feedback hex=5647463101030000 invalid:direction
30 invalid-terminal-receipt-body 1 gateway_feedback hex=5647463100030001ff invalid:length
31 invalid-terminal-receipt-truncated 1 gateway_feedback hex=56474631000300 invalid:truncated
32 invalid-terminal-receipt-length 1 gateway_feedback hex=5647463100030001 invalid:length
33 invalid-feedback-type 1 gateway_feedback hex=5647463100040000 invalid:type
34 invalid-feedback-length 1 gateway_feedback hex=5647463101100003000000 invalid:length
+8 -5
View File
@@ -1,6 +1,9 @@
id version kind input expected id version kind input expected
tunnel-current 1 tunnel offered=1;feature=control.v1 valid tunnel-current 2 tunnel offered=2;feature=control.v2 valid
tunnel-n-minus-1 0 tunnel offered=0;feature=control.v1 valid tunnel-n-minus-1 1 tunnel offered=1;feature=control.v1 valid
tunnel-n-minus-2 -1 tunnel offered=-1;feature=control.v1 valid tunnel-n-minus-2 0 tunnel offered=0;feature=control.v1 valid
tunnel-unsupported 1 tunnel offered=2;feature=control.v1 invalid:unsupported_version tunnel-display-request 2 tunnel offered=2;feature=display.request.v1 valid
tunnel-no-control 1 tunnel offered=1;feature=media.video invalid:unsupported_feature tunnel-absolute-input 2 tunnel offered=2;feature=input.absolute.v1 valid
tunnel-scroll-input 2 tunnel offered=2;feature=input.scroll.v1 valid
tunnel-unsupported 2 tunnel offered=3;feature=control.v2 invalid:unsupported_version
tunnel-no-control 2 tunnel offered=2;feature=media.video invalid:unsupported_feature
1 id version kind input expected
2 tunnel-current 1 2 tunnel offered=1;feature=control.v1 offered=2;feature=control.v2 valid
3 tunnel-n-minus-1 0 1 tunnel offered=0;feature=control.v1 offered=1;feature=control.v1 valid
4 tunnel-n-minus-2 -1 0 tunnel offered=-1;feature=control.v1 offered=0;feature=control.v1 valid
5 tunnel-unsupported tunnel-display-request 1 2 tunnel offered=2;feature=control.v1 offered=2;feature=display.request.v1 invalid:unsupported_version valid
6 tunnel-no-control tunnel-absolute-input 1 2 tunnel offered=1;feature=media.video offered=2;feature=input.absolute.v1 invalid:unsupported_feature valid
7 tunnel-scroll-input 2 tunnel offered=2;feature=input.scroll.v1 valid
8 tunnel-unsupported 2 tunnel offered=3;feature=control.v2 invalid:unsupported_version
9 tunnel-no-control 2 tunnel offered=2;feature=media.video invalid:unsupported_feature
@@ -0,0 +1,9 @@
{
"username": "alice",
"provider": "local",
"roles": ["user"],
"role": "user",
"native_identity": {
"client_device_id": "device-1"
}
}
@@ -0,0 +1,10 @@
{
"username": "alice",
"provider": "local",
"roles": ["user"],
"role": "user",
"native_identity": {
"client_device_id": "device-1",
"device_key_id": "key-1"
}
}
@@ -6,7 +6,7 @@
"gateway": { "gateway": {
"id": "gateway-1", "id": "gateway-1",
"addresses": ["gateway.control.test:443"], "addresses": ["gateway.control.test:443"],
"public_identity": "gateway-1" "public_identity": "gateway.control.test"
}, },
"tunnel": { "tunnel": {
"versions": ["verse-gateway-v1/1"], "versions": ["verse-gateway-v1/1"],
@@ -0,0 +1,6 @@
{
"username": "alice",
"provider": "local",
"roles": ["user"],
"role": "user"
}
@@ -0,0 +1,7 @@
{
"client_device_id": "device-1",
"device_key_id": "key-1",
"pool_id": "pool-1",
"idempotency_key": "request-1",
"policy_snapshot": {}
}
+6 -1
View File
@@ -3,8 +3,13 @@
"files": [ "files": [
"fixtures/conformance/control-v1.tsv", "fixtures/conformance/control-v1.tsv",
"fixtures/conformance/datagram-v1.tsv", "fixtures/conformance/datagram-v1.tsv",
"fixtures/conformance/datagram-v2.tsv",
"fixtures/conformance/device-proof-v1.tsv",
"fixtures/conformance/events-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" "fixtures/conformance/tunnel-v1.tsv"
], ],
"corpus_sha256": "c91a512dc67aa9912b31b21144be2adfeacf0dc80dd8515bd3b4a8f52977e761" "corpus_sha256": "6d2ce3a855b2fa45733a5f7b5b4c2e68448cceed5dfbca535ec81fe8cf230b30"
} }
@@ -0,0 +1,6 @@
{
"username": "alice",
"provider": "local",
"roles": ["user"],
"role": "user"
}
@@ -0,0 +1,10 @@
{
"username": "alice",
"provider": "local",
"roles": ["user"],
"role": "user",
"native_identity": {
"client_device_id": "device-1",
"device_key_id": "key-1"
}
}
+1 -1
View File
@@ -6,7 +6,7 @@
"gateway": { "gateway": {
"id": "gateway-1", "id": "gateway-1",
"addresses": ["gateway.control.test:443"], "addresses": ["gateway.control.test:443"],
"public_identity": "gateway-1" "public_identity": "gateway.control.test"
}, },
"tunnel": { "tunnel": {
"versions": ["verse-gateway-v1/1"], "versions": ["verse-gateway-v1/1"],
@@ -0,0 +1,7 @@
{
"client_device_id": "device-1",
"device_key_id": "key-1",
"certificate_chain_pem": "-----BEGIN CERTIFICATE-----\nfixture\n-----END CERTIFICATE-----",
"trust_bundle_pem": "-----BEGIN CERTIFICATE-----\nfixture-ca\n-----END CERTIFICATE-----",
"expires_at": "2099-01-01T00:00:00Z"
}
+6
View File
@@ -0,0 +1,6 @@
{
"client_device_id": "device-1",
"device_key_id": "key-1",
"pool_id": "pool-1",
"idempotency_key": "request-1"
}
+17 -5
View File
@@ -1,7 +1,9 @@
# VerseVDI control datagram v1 # VerseVDI control datagram v1
Phase 3A reserves a bounded control-datagram envelope. It does not forward video, Phase 3C adds bounded encoded video/audio and sequenced-input channels to the same
audio, provider traffic, VM traffic, or arbitrary binary clipboard data. transport envelope. The gateway forwards encoded bytes; it does not decode, encode,
transcode, render, or expose provider traffic. Arbitrary binary clipboard data remains
rejected.
All multi-byte integers are unsigned big-endian. The fixed header is 21 bytes: All multi-byte integers are unsigned big-endian. The fixed header is 21 bytes:
@@ -22,6 +24,16 @@ Truncated, oversized, unknown-version, unknown-channel, invalid-fragment, and
length-mismatch frames are rejected before allocation proportional to the claimed length-mismatch frames are rejected before allocation proportional to the claimed
payload. Media/provider identifiers are not registered channels. payload. Media/provider identifiers are not registered channels.
Registered Phase 3A channels are `control.ack.v1`, `control.cancel.v1`, and Registered channels are `control.ack.v1`, `control.cancel.v1`, `clipboard.text.v1`,
`clipboard.text.v1`. Clipboard payloads are UTF-8 JSON text contracts and remain `media.video.v1`, `media.audio.v1`, and `input.sequenced.v1`. Media/input frames use
subject to the 65,536-byte text limit and explicit authorization. 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
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.
+31
View File
@@ -0,0 +1,31 @@
# VerseVDI complete media datagram v2
Datagram v2 carries one complete encoded video or audio unit under one sequence.
It is negotiated explicitly as `datagram-v2`; v1 bytes are never reinterpreted.
The gateway relays encoded bytes without codec processing or provider exposure.
All multi-byte integers are unsigned big-endian. The fixed header is 23 bytes:
| Offset | Size | Field | Rule |
|---:|---:|---|---|
| 0 | 2 | magic | ASCII `VD` (`0x56 0x44`) |
| 2 | 1 | version | `2` only |
| 3 | 1 | channel | `media.video.v1` (10) or `media.audio.v1` (11) |
| 4 | 1 | flags | zero; unknown bits reject |
| 5 | 4 | sequence | session-local wrapping complete-unit sequence |
| 9 | 8 | timestamp_ms | sender timestamp, bounded by transport skew policy |
| 17 | 2 | fragment_index | zero-based |
| 19 | 2 | fragment_count | 1 through 891; index less than count |
| 21 | 2 | payload_length | exact payload byte count, at most 1,177 |
Each QUIC datagram is at most 1,200 bytes. One complete unit is at most
1,048,576 encoded bytes and 891 fragments. A sender rejects a larger unit
before fragmentation.
A receiver retains at most four incomplete media units and only received
fragment bytes. Fragments for one unit must agree on channel, sequence,
timestamp, flags, and count. Exact duplicates are ignored; conflicting
duplicates reject that unit. Bounded reorder is accepted. An incomplete unit
expires after 250 milliseconds, and accepting a fifth incomplete unit evicts
the oldest. Reassembly checks the 1,048,576-byte ceiling before appending and
emits only after every fragment is present.
+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.
+89
View File
@@ -0,0 +1,89 @@
# 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
kind encoding; the new kinds require explicit feature negotiation and a new
immutable Protocol release candidate.
## `input.sequenced.v1` payload (`VGI1`)
All multibyte fields are big-endian and unsigned unless a field is explicitly
marked signed. 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. |
| `0x06` | absolute mouse | `x` (u16), `y` (u16), `viewport_width` (u16), `viewport_height` (u16). Viewport dimensions must be nonzero and each coordinate must be strictly smaller than its corresponding dimension. |
| `0x07` | high-resolution scroll | `vertical_delta` (i16), `horizontal_delta` (i16). Positive and negative values preserve the client scroll direction without provider-specific scaling. |
Keyboard, mouse button, UTF-8, controller, absolute mouse, and scroll messages
are delivered over the gateway's reliable ordered input flow. Relative and
absolute mouse movement and scroll are state changes, not pressed-state
entries. Kinds `0x06` and `0x07` are accepted only when the session advertises
`input.absolute.v1` and `input.scroll.v1`, respectively. The gateway maps the
validated values to the provider's separate input 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), `0x02` FEC
status, and `0x03` terminal receipt (empty). FEC status contains
`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 terminal receipt is valid only from client to gateway with an exact
zero-byte payload. Session-state authorization remains a gateway responsibility;
the Protocol grammar defines only its fixed wire shape.
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.
+7 -3
View File
@@ -1,12 +1,16 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"version": "1", "version": "1",
"framing_profiles": ["datagram-v1", "datagram-v2"],
"header_bytes": 21, "header_bytes": 21,
"maximum_frame_bytes": 65536, "maximum_frame_bytes": 65536,
"channels": [ "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": 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": 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, "payload_profile": "gateway-input-v1"}
], ],
"reserved_rejected": ["video", "audio", "provider", "vm", "file-transfer", "clipboard.binary"] "reserved_rejected": ["provider", "vm", "file-transfer", "clipboard.binary"]
} }
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -1,18 +1,18 @@
{ {
"compatibility": { "compatibility": {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"current": "1", "current": "2",
"datagram_registry": "registries/datagrams.json", "datagram_registry": "registries/datagrams.json",
"feature_registry": "registries/features.json", "feature_registry": "registries/features.json",
"n_minus_1": "0", "n_minus_1": "1",
"n_minus_2": "-1", "n_minus_2": "0",
"protocol": "versevdi-control", "protocol": "versevdi-control",
"unsupported": [ "unsupported": [
"-2", "-1",
"2" "3"
] ]
}, },
"generator_sha256": "cb975bcd42bf77641b6a0f44d5ec7a6fdba1858d6b8a865e0f04e53bad82648c", "generator_sha256": "00c1905fc611ca9e226cd90da761b48b8e203734b10542befea397a30082d360",
"protocol_version": "1.0.0", "protocol_version": "1.0.0",
"schema_sha256": "36e4c8bac2eae674c1eba551c6ca8c64bf80fcc092ca63ec89a2c71c2bec86e1" "schema_sha256": "762d009c3d25d80c3850d975e45f7a6b3fd8adf5c93c8fa7dd11dfa993f8bbb1"
} }
Binary file not shown.
Binary file not shown.
+1924 -133
View File
File diff suppressed because it is too large Load Diff
+2204 -187
View File
File diff suppressed because it is too large Load Diff
+55 -19
View File
@@ -2,7 +2,7 @@ openapi: 3.1.0
info: info:
title: VerseVDI control contract title: VerseVDI control contract
version: 1.0.0 version: 1.0.0
description: Bounded Phase 3A control-plane wire contract; no provider or media endpoints. description: Bounded control-plane wire contract. The current SessionRequest shape is control wire version 2; no provider or media endpoints.
servers: servers:
- url: / - url: /
tags: tags:
@@ -23,18 +23,11 @@ paths:
$ref: ../schemas/control-v1.schema.json#/$defs/LoginRequest $ref: ../schemas/control-v1.schema.json#/$defs/LoginRequest
responses: responses:
'200': '200':
description: Browser session established through secure cookies. description: Browser session established through secure cookies; native identity is structurally forbidden.
content: content:
application/json: application/json:
schema: schema:
type: object $ref: ../schemas/control-v1.schema.json#/$defs/BrowserAuthenticatedSession
additionalProperties: false
required: [username, provider, roles, role]
properties:
username: {type: string, minLength: 1, maxLength: 256}
provider: {type: string, minLength: 1, maxLength: 64}
roles: {type: array, maxItems: 16, items: {type: string, maxLength: 64}}
role: {type: string, enum: [user, admin]}
'400': {$ref: '#/components/responses/InvalidRequest'} '400': {$ref: '#/components/responses/InvalidRequest'}
'401': {$ref: '#/components/responses/Unauthorized'} '401': {$ref: '#/components/responses/Unauthorized'}
'503': {$ref: '#/components/responses/Unavailable'} '503': {$ref: '#/components/responses/Unavailable'}
@@ -66,18 +59,32 @@ paths:
- nativeBearer: [] - nativeBearer: []
responses: responses:
'200': '200':
description: Authenticated subject summary. description: Route-context-specific authenticated subject summary. Browser responses forbid native identity; native bearer responses require it.
content: content:
application/json: application/json:
schema: schema:
type: object oneOf:
additionalProperties: false - $ref: ../schemas/control-v1.schema.json#/$defs/BrowserAuthenticatedSession
required: [username, provider, roles, role] - $ref: ../schemas/control-v1.schema.json#/$defs/NativeAuthenticatedSession
properties: '401': {$ref: '#/components/responses/Unauthorized'}
username: {type: string, minLength: 1, maxLength: 256} /api/v1/auth/tunnel-credentials:
provider: {type: string, minLength: 1, maxLength: 64} post:
roles: {type: array, maxItems: 16, items: {type: string, maxLength: 64}} tags: [authentication]
role: {type: string, enum: [user, admin]} operationId: issueNativeTunnelCredential
security:
- nativeBearer: []
responses:
'200':
description: Short-lived native tunnel credential. The response must not be stored.
headers:
Cache-Control:
description: Prevent storage of the credential response.
required: true
schema: {type: string, const: no-store}
content:
application/json:
schema:
$ref: ../schemas/control-v1.schema.json#/$defs/NativeTunnelCredential
'401': {$ref: '#/components/responses/Unauthorized'} '401': {$ref: '#/components/responses/Unauthorized'}
/api/v1/auth/reauth: /api/v1/auth/reauth:
post: post:
@@ -85,6 +92,8 @@ paths:
operationId: issueReauthenticationGrant operationId: issueReauthenticationGrant
security: security:
- browserSession: [] - browserSession: []
browserCsrfCookie: []
browserCsrfHeader: []
requestBody: requestBody:
required: true required: true
content: content:
@@ -106,6 +115,8 @@ paths:
operationId: logoutSession operationId: logoutSession
security: security:
- browserSession: [] - browserSession: []
browserCsrfCookie: []
browserCsrfHeader: []
- nativeBearer: [] - nativeBearer: []
responses: responses:
'204': {description: Session revoked and browser cookies cleared.} '204': {description: Session revoked and browser cookies cleared.}
@@ -116,6 +127,8 @@ paths:
operationId: registerDevice operationId: registerDevice
security: security:
- browserSession: [] - browserSession: []
browserCsrfCookie: []
browserCsrfHeader: []
requestBody: requestBody:
required: true required: true
content: content:
@@ -137,6 +150,8 @@ paths:
operationId: proveDevice operationId: proveDevice
security: security:
- browserSession: [] - browserSession: []
browserCsrfCookie: []
browserCsrfHeader: []
parameters: parameters:
- $ref: '#/components/parameters/DeviceID' - $ref: '#/components/parameters/DeviceID'
requestBody: requestBody:
@@ -160,6 +175,8 @@ paths:
operationId: revokeDevice operationId: revokeDevice
security: security:
- browserSession: [] - browserSession: []
browserCsrfCookie: []
browserCsrfHeader: []
parameters: parameters:
- $ref: '#/components/parameters/DeviceID' - $ref: '#/components/parameters/DeviceID'
responses: responses:
@@ -189,8 +206,11 @@ paths:
post: post:
tags: [broker] tags: [broker]
operationId: requestBrokerSession operationId: requestBrokerSession
description: Control wire version 2 endpoint. Legacy version-1 SessionRequest payloads containing client-supplied policy_snapshot are rejected.
security: security:
- browserSession: [] - browserSession: []
browserCsrfCookie: []
browserCsrfHeader: []
- nativeBearer: [] - nativeBearer: []
parameters: parameters:
- $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/IdempotencyKey'
@@ -241,6 +261,8 @@ paths:
operationId: allocateBrokerSession operationId: allocateBrokerSession
security: security:
- browserSession: [] - browserSession: []
browserCsrfCookie: []
browserCsrfHeader: []
- nativeBearer: [] - nativeBearer: []
parameters: parameters:
- $ref: '#/components/parameters/SessionID' - $ref: '#/components/parameters/SessionID'
@@ -266,6 +288,8 @@ paths:
operationId: reconnectBrokerSession operationId: reconnectBrokerSession
security: security:
- browserSession: [] - browserSession: []
browserCsrfCookie: []
browserCsrfHeader: []
- nativeBearer: [] - nativeBearer: []
parameters: parameters:
- $ref: '#/components/parameters/SessionID' - $ref: '#/components/parameters/SessionID'
@@ -292,6 +316,8 @@ paths:
operationId: cancelBrokerSession operationId: cancelBrokerSession
security: security:
- browserSession: [] - browserSession: []
browserCsrfCookie: []
browserCsrfHeader: []
- nativeBearer: [] - nativeBearer: []
parameters: parameters:
- $ref: '#/components/parameters/SessionID' - $ref: '#/components/parameters/SessionID'
@@ -345,6 +371,16 @@ components:
type: apiKey type: apiKey
in: cookie in: cookie
name: versevdi_session name: versevdi_session
browserCsrfCookie:
type: apiKey
in: cookie
name: versevdi_csrf
description: Must be identical to X-CSRF-Token and is checked against Server session state.
browserCsrfHeader:
type: apiKey
in: header
name: X-CSRF-Token
description: Must be identical to the versevdi_csrf cookie and is checked against Server session state.
nativeBearer: nativeBearer:
type: http type: http
scheme: bearer scheme: bearer
@@ -0,0 +1,24 @@
## Decisions
- Use the existing generated JSON binding pipeline and add only object definitions that
consumers need now.
- Keep registration/authority messages distinct from `ConnectionManifest`; the latter is
client-facing and contains no provider route, certificate, identity, or credential.
- Encode capability domains as bounded registered strings (`transport`, `framing`, `media`,
`audio`, `source_rate_control`, `client_decode`) so unknown or empty required domains
cannot silently fall back.
- Use a fixed 21-byte big-endian datagram header with application flow IDs and a payload
limit below the path MTU; control channels 1-3 remain compatible with Phase 3A.
## Bounds and failure behavior
All arrays, strings, payloads, fragments, and timestamps are bounded by the JSON schema or
frame registry. Generated decoders reject unknown fields, trailing values, invalid versions,
and missing required fields. Provider address, RTSP, credential, and private-key names are
not added to client-facing definitions.
## Compatibility
The current wire version remains `1`; current, N-1, and N-2 declarations remain unchanged.
New identifiers are additive. Consumers must reject an unknown major or no-overlap selection
before provider launch.
@@ -0,0 +1,32 @@
## Why
Phase 3A defines gateway-only manifests and control datagrams but has no versioned
contracts for the separately deployed gateway's registration, admission authority,
capability intersection, lifecycle, or encoded relay framing. Phase 3C-G needs those
contracts frozen before Data Plane or Connection Server consumers change.
## What Changes
- Add bounded JSON bindings for gateway registration, heartbeat/drain, tunnel admission,
session-scoped authority, capability selection, channel framing, provider state, and
stable errors.
- Extend the tunnel protobuf descriptor with the same versioned control messages.
- Register only gateway-owned media/input flow identifiers; keep provider endpoints and
credentials out of client-facing manifests.
## Ownership and provenance
Protocol owns the wire contract. The contract is original VerseVDI work derived from the
Phase 3A schemas and public Apollo/Moonlight behavior recorded in the Planning Hub. No
GPL source is copied into this repository.
## Non-goals
- Provider implementation, database policy, QUIC implementation, media decoding, or client
rendering.
- Direct client-to-Apollo routing, provider endpoint exposure, or a speculative plugin ABI.
## Stop conditions
Unknown versions, malformed bounds, no capability overlap, downgrade without explicit
acknowledgement, forbidden provider fields, and oversized frames fail closed.
@@ -0,0 +1,37 @@
## ADDED Requirements
### Requirement: Versioned gateway authority contracts
Gateway registration, heartbeat, drain, admission, capability selection, channel framing,
provider state, and stable errors SHALL use bounded versioned objects with strict decoding.
#### Scenario: Unknown or malformed gateway message
- **WHEN** a consumer receives an unknown version, missing required field, unknown field,
invalid bound, or trailing JSON value
- **THEN** it rejects the message before allocating provider or media state with a stable
validation error.
### Requirement: Gateway-only client manifest
Client-facing manifests SHALL expose only the selected gateway, tunnel/profile identifiers,
policy bounds, opaque grant, audience, expiry, session, and correlation data.
#### Scenario: Provider route injection
- **WHEN** a manifest or client-facing authority contains a provider address, RTSP URL,
certificate, pairing identity, credential, or private key field
- **THEN** schema validation rejects it and no generated client binding accepts it.
### Requirement: Explicit capability intersection
Transport, framing, media, audio, source-rate-control, and client-decode capabilities SHALL
remain separate and no-overlap or unknown-required-profile results SHALL fail closed.
#### Scenario: No capability overlap
- **WHEN** policy, gateway, provider, and client capabilities have no allowed intersection
- **THEN** negotiation returns a stable no-overlap error before provider launch or media flow.
### Requirement: Bounded encoded datagrams
Encoded media and approved sequenced input SHALL use registered application flow identifiers,
validated fixed headers, bounded fragments, and payload bytes that are not codec-transformed.
#### Scenario: Malformed or oversized datagram
- **WHEN** a datagram is truncated, has an unknown flow, invalid fragment, length mismatch,
or exceeds its registered payload limit
- **THEN** the datagram is rejected without allocation proportional to the claimed payload.
@@ -0,0 +1,5 @@
- [x] Add versioned gateway and authority definitions to the schema and tunnel descriptor.
- [x] Add registered media/audio/sequenced-input flow identifiers and positive/negative fixtures.
- [x] Regenerate Go/Rust/Swift bindings and descriptor outputs.
- [x] Run strict validation, cross-language conformance, and deterministic fixture hashing.
- [x] Freeze the local Protocol candidate commit and record its hash for consumers.
@@ -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.
- [x] 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.
- [x] 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
- [x] 3.1 Reconcile the canonical specification, OpenSpec tasks, source
provenance, and consumer fixture digest before creating a new immutable
Protocol release candidate.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-30
@@ -0,0 +1,27 @@
## Context
RC8's canonical requirement names a terminal receipt, but its frame grammar, fixed fixture, and all Protocol validators reject the exact receipt accepted by Data Plane. The existing VGF1 envelope and generated conformance machinery already cover the required boundary.
## Goals / Non-Goals
**Goals:**
- Make one fixed empty client-direction type `0x03` receipt valid in every Protocol validator.
- Preserve all existing VGF1 direction, type, size, and payload validation.
- Make independent clients able to construct the receipt from Protocol-owned evidence.
**Non-Goals:**
- No new envelope, feedback framework, transport, or acknowledgement protocol.
- No change to gateway-to-client termination, rumble, HDR, client IDR, or FEC payloads.
## Decisions
- Extend the existing VGF1 grammar and fixed TSV corpus; generated and native validators remain consumers of that single contract.
- Reserve type `0x03` only for client direction with a zero-length payload. Direction and exact-length checks remain prior trust-boundary requirements.
- Publish the verified change as a new immutable version; RC8 remains unchanged.
## Risks / Trade-offs
- [A validator diverges from the fixed corpus] → Require normalized cross-language conformance in `make verify`.
- [Receipt handling is accepted outside terminal state] → Keep state authorization in Data Plane; Protocol validates only the wire shape.
@@ -0,0 +1,23 @@
## Why
The canonical terminal-receipt requirement contradicts the fixed VGF1 grammar and every generated validator, so RC8 cannot provide an executable cross-language contract for gateway-owned terminal closure.
## What Changes
- Define client-direction VGF1 type `0x03` as an empty terminal receipt in the existing frame grammar.
- Add fixed conformance vectors for valid receipt handling and invalid direction, body, truncation, length, and unknown-type cases.
- Generate consistent Go, Rust, Swift, and Python validation behavior from the Protocol source.
## Capabilities
### New Capabilities
None.
### Modified Capabilities
- `gateway-input-feedback`: Make the already-required terminal receipt executable and cross-language conformant without weakening other VGF1 validation.
## Impact
Protocol frame documentation, conformance fixtures, generators, generated bindings, native validator tools, and immutable Protocol consumers. RC8 remains unchanged and a new immutable Protocol version is required.
@@ -0,0 +1,27 @@
## MODIFIED Requirements
### 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, FEC/loss feedback, and client-direction type
`0x03` with an empty payload as terminal receipt SHALL be valid from the client
to the gateway. The fixed conformance corpus and every generated or native
Protocol validator SHALL accept that exact receipt and reject unknown types,
wrong direction, nonempty receipt bodies, truncation, and length mismatch. The
terminal receipt SHALL be valid only while the same session awaits receipt of
its one terminal event and MUST NOT be forwarded to the provider. 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: Terminal event receipt
- **WHEN** a client receives the reliable typed terminal event
- **THEN** it sends the fixed empty client-direction type `0x03` receipt and the gateway owns bounded tunnel closure without forwarding the receipt to the provider
#### Scenario: Unauthorized or malformed feedback
- **WHEN** feedback is disabled by policy, has an invalid direction/type/length, contains a forbidden provider field, or sends a terminal receipt outside the awaiting-terminal state
- **THEN** the gateway rejects it without forwarding or provider mutation
@@ -0,0 +1,15 @@
## 1. Red Conformance
- [x] 1.1 Add fixed valid and invalid terminal-receipt vectors and prove Python, Go, Rust, and Swift reject the required valid vector
- [x] 1.2 Add a Data Plane independent-client regression that consumes the Protocol fixed vector rather than the production encoder
## 2. Contract Repair
- [x] 2.1 Update the VGF1 grammar and Protocol validator sources for the exact empty client-direction type `0x03` receipt
- [x] 2.2 Regenerate bindings normally and prove deterministic generation has no drift
- [x] 2.3 Run complete Protocol verification and strict OpenSpec validation
## 3. Immutable Release
- [x] 3.1 Verify the next version is unused locally and remotely, publish one immutable annotated tag, and verify its object and peeled commit
- [x] 3.2 Resolve the version from separate empty caches and pin exact fetched checksums in Data Plane and Connection Server
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-30
@@ -0,0 +1,26 @@
## Context
QUIC stream writes complete before peer receipt. The previous gateway avoided losing its last control frame by waiting for client-owned connection closure; immediate gateway closure reproduced event loss. `control.ack.v1` is already reliable and bidirectional, so no new flow is needed.
## Goals / Non-Goals
**Goals:**
- Represent peer receipt of the one terminal control event.
- Keep the message bounded, direction-specific, and independent of provider data.
**Non-Goals:**
- General acknowledgements, retries, lifecycle state, or provider transport semantics.
- Changes to JSON schemas or generated bindings.
## Decisions
- Assign client-direction `VGF1` type `0x03` with an empty payload to terminal receipt.
- Permit it only after a terminal event; the Data Plane enforces session state and deadline.
- Preserve all existing message bytes and meanings.
## Risks / Trade-offs
- [Older clients do not send the receipt] → The gateway closes at its bounded receipt deadline; compatibility does not transfer tunnel ownership back to the client.
- [A stale receipt is replayed] → The gateway rejects receipts outside the single awaiting-terminal state.
@@ -0,0 +1,23 @@
## Why
A public independent QUIC regression proved that closing the gateway connection immediately after writing the reliable terminal event can discard that event, while waiting for the client to close leaves session ownership with the client. The registered bidirectional control flow needs one bounded receipt semantic so the gateway can close only after observed delivery or a fixed receipt deadline.
## What Changes
- Add a client-to-gateway terminal receipt to the existing bounded `VGF1` envelope on `control.ack.v1`.
- Keep the receipt payload empty and valid only while one terminal event is awaiting receipt.
- Preserve the existing IDR, FEC, termination, rumble, and HDR meanings.
## Capabilities
### New Capabilities
None.
### Modified Capabilities
- `gateway-input-feedback`: Permit the narrowly scoped terminal receipt in the existing reliable control envelope.
## Impact
This changes the immutable Protocol semantics consumed by the GPLv3 Data Plane and independent Verse clients. It adds no schema field, dependency, provider address, credential, generic acknowledgement framework, or provider-facing message.
@@ -0,0 +1,23 @@
## MODIFIED Requirements
### 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, FEC/loss feedback, and an empty terminal receipt
SHALL be valid from the client to the gateway. The terminal receipt SHALL be
valid only while the same session awaits receipt of its one terminal event and
MUST NOT be forwarded to the provider. 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: Terminal event receipt
- **WHEN** a client receives the reliable typed terminal event
- **THEN** it sends the empty terminal receipt and the gateway owns bounded tunnel closure without forwarding the receipt to the provider
#### Scenario: Unauthorized or malformed feedback
- **WHEN** feedback is disabled by policy, has an invalid direction/type/length, contains a forbidden provider field, or sends a terminal receipt outside the awaiting-terminal state
- **THEN** the gateway rejects it without forwarding or provider mutation
@@ -0,0 +1,9 @@
## 1. Contract
- [x] 1.1 Define the empty client-direction terminal receipt on `control.ack.v1`
- [x] 1.2 Run complete Protocol verification and deterministic generation checks
## 2. Immutable release
- [x] 2.1 Publish one new never-reused immutable Protocol version after final contract verification
- [x] 2.2 Resolve the version from separate empty consumer caches and record exact checksums
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-29
@@ -0,0 +1,38 @@
## Context
The Data Plane already collects process-wide atomic counters and gauges. Only active connections and egress Kbps cross the authenticated heartbeat boundary.
## Goals / Non-Goals
**Goals:**
- Carry the existing low-cardinality observations with explicit units.
- Bound every numeric field and enumerate provider state.
- Keep registration capacity distinct from measured traffic.
**Non-Goals:**
- Add session, route, endpoint, credential, label, or payload fields.
- Define a new telemetry transport.
- Publish a Protocol version.
## Decisions
- Nest the values in required `GatewayTelemetry` so heartbeat telemetry is one strict atomic contract.
- Use cumulative counters and microsecond delay totals plus one processing sample per complete provider media unit; consumers can derive rates/averages without losing raw observations.
- Define queue delay as residence in the bounded provider queue, processing as active recovery/framing/QUIC work excluding queue and scheduler waits, and pacing as scheduler wait only.
- Derive measured egress from transmitted-byte deltas over monotonic elapsed time; configured capacity remains registration data.
- Keep loss as parts per million and provider state as a bounded enum.
## Risks / Trade-offs
- [Cumulative counters approach signed integer limits] → Bound at signed 64-bit and saturate consumer conversions.
- [New required object breaks RC6] → Test locally and publish only under separate immutable-version authorization.
## Migration Plan
Regenerate all bindings locally, update both consumers through the temporary workspace, and stop at the immutable publication boundary.
## Open Questions
None.
@@ -0,0 +1,23 @@
## Why
Gateway heartbeat currently carries active sessions and one egress rate but cannot transport the required observed process and provider-path telemetry.
## What Changes
- Add one required bounded low-cardinality telemetry object to authenticated gateway heartbeat.
- Cover counters, delay totals/samples, control RTT/loss/jitter, pending reliability, reconnects, and provider state.
- Keep configured capacity exclusively in registration and measured egress in heartbeat.
## Capabilities
### New Capabilities
- `gateway-heartbeat-telemetry`: Authenticated heartbeats carry bounded observed gateway telemetry without routes, sessions, credentials, or payload data.
### Modified Capabilities
None.
## Impact
The control-v1 schema, generated Go/Rust/Swift bindings, conformance checks, and both unpublished consumers require coordinated local updates. RC6 remains unchanged.
@@ -0,0 +1,22 @@
## ADDED Requirements
### Requirement: Heartbeat carries observed gateway telemetry
Every authenticated `GatewayHeartbeat` SHALL carry the bounded process-level counters, delay totals and samples, control RTT/loss/jitter, pending reliable work, reconnect count, and provider state defined by `GatewayTelemetry`.
#### Scenario: Valid telemetry heartbeat
- **WHEN** a gateway reports its current observed snapshot
- **THEN** Go, Rust, and Swift bindings accept the same bounded low-cardinality values and units
### Requirement: Heartbeat telemetry excludes sensitive dimensions
Heartbeat telemetry MUST reject unknown fields and MUST NOT include session, route, endpoint, credential, label, or payload values.
#### Scenario: Secret or high-cardinality field is attempted
- **WHEN** a heartbeat contains an unregistered session, route, endpoint, credential, or payload field
- **THEN** strict contract validation rejects it before authenticated transport
### Requirement: Delay and egress observations have one canonical meaning
Queue delay SHALL measure provider-queue residence, processing delay SHALL measure active gateway recovery/framing/QUIC work excluding queue and pacing, and pacing delay SHALL measure scheduler waiting only. Processing samples SHALL count complete provider media units rather than Verse fragments. Measured egress SHALL derive from transmitted-byte deltas over monotonic elapsed time and MUST NOT be copied from configured capacity.
#### Scenario: One provider unit becomes multiple Verse frames
- **WHEN** one complete provider unit waits in the queue, traverses gateway processing, waits for pacing, and fragments into multiple Verse frames
- **THEN** each delay total includes only its defined interval and the heartbeat advances processing samples exactly once
@@ -0,0 +1,12 @@
## 1. Contract
- [x] 1.1 Add bounded GatewayTelemetry to every heartbeat
- [x] 1.2 Add Go, Rust, and Swift strict conformance checks
- [x] 1.3 Regenerate bindings and prove deterministic output
- [x] 1.4 Specify queue, processing, pacing, sample, and measured-egress semantics
## 2. Consumer Boundary
- [x] 2.1 Verify local Data Plane and Server consumers through a temporary workspace
- [x] 2.2 Publish one new never-reused immutable Protocol version under separate authorization
- [x] 2.3 Resolve from empty caches and pin exact checksums in both consumers
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-29
@@ -0,0 +1,42 @@
## Context
The Server resolves an immutable stream-policy version, but RC6 provider work carries only its identifier. The Data Plane consequently cannot distinguish the authorized settings from local defaults.
## Goals / Non-Goals
**Goals:**
- Carry only the effective launch settings required by the provider boundary.
- Express client decode support as an ordered set of existing registered profiles.
- Generate the same ordered registered-profile intersection for every consumer.
- Generate identical validation from the canonical schema for all bindings.
- Preserve the policy-version identifier for audit correlation.
**Non-Goals:**
- Publish or mutate RC6.
- Add a provider-specific token grammar or generic capability framework.
- Expose provider work or policy internals to Verse clients.
## Decisions
- Use one required nested `ProviderStreamPolicy` value in `ProviderSessionWork`; this keeps the policy settings atomic and avoids repeating validation.
- Carry the Server-selected target bitrate rather than all policy bounds because Apollo ANNOUNCE consumes one configured bitrate.
- Permit canonical `H264`, `HEVC`, and `AV1` values in the contract. A provider implementation must reject values it cannot honor rather than silently downgrade them.
- Carry `audio_enabled` even though the current Apollo path cannot truthfully disable audio; the Data Plane must fail closed for that combination.
- Change `client_decode` from one opaque string to a non-empty ordered unique array of registered profile identifiers. Preference belongs to the first peer's order.
- Generate `IntersectCapabilityProfiles` from the canonical schema so Protocol, Server, and Data Plane do not maintain separate interpretations.
## Risks / Trade-offs
- [New required field breaks RC6 consumers] → Publish only under a separately authorized new immutable version and pin both consumers after empty-cache resolution.
- [Provider capabilities differ] → Validate the effective policy against the selected provider before readiness.
- [Peers advertise no common registered profile] → Reject admission instead of inventing a combined token or silently downgrading.
## Migration Plan
Regenerate and verify bindings locally, update both consumers through a temporary workspace only, then stop at the publication boundary. RC6 remains unchanged.
## Open Questions
None.
@@ -0,0 +1,24 @@
## Why
Provider work identifies an immutable stream-policy version but omits the effective settings, allowing a gateway to launch Apollo with unrelated hard-coded media parameters.
## What Changes
- Add the effective resolution, frame rate, codec, selected bitrate, and audio policy to authenticated provider work.
- Represent decode support as an ordered set of registered profiles and generate one canonical intersection operation for consumers.
- Require generated Go, Rust, and Swift bindings to validate the same bounded stream-policy contract.
- Keep the new contract unpublished until a new immutable Protocol version is separately authorized.
## Capabilities
### New Capabilities
- `provider-stream-policy`: Authenticated provider work carries the exact effective stream policy consumed by the provider launch, and registered peers negotiate that policy through the shared ordered profile intersection.
### Modified Capabilities
None.
## Impact
The control-v1 schema, generated bindings, conformance fixtures, and downstream Server and Data Plane consumers require coordinated local updates. RC6 remains immutable and unchanged.
@@ -0,0 +1,33 @@
## ADDED Requirements
### Requirement: Provider work carries the effective stream policy
Authenticated `ProviderSessionWork` SHALL carry the immutable policy version and its effective resolution, frame rate, codec, target bitrate, and audio-enabled decision.
#### Scenario: Gateway receives an effective policy
- **WHEN** the Server issues provider work for an admitted session
- **THEN** the work identifies the policy version and includes the effective bounded stream-policy values
### Requirement: Stream-policy bindings share one strict contract
Generated Go, Rust, and Swift bindings MUST reject missing, unknown, out-of-range, or unsupported stream-policy wire values according to the canonical schema.
#### Scenario: Invalid policy is rejected consistently
- **WHEN** provider work contains an unknown codec or a value outside the canonical bounds
- **THEN** every generated binding rejects the work before it can reach provider setup
### Requirement: Decode capabilities use registered ordered profiles
`CapabilityProfile.client_decode` SHALL be a non-empty ordered unique set containing only registered `h264-opus` and `hevc-opus` profile identifiers. It MUST NOT encode multiple capabilities in an opaque private token.
#### Scenario: Independent peer advertises one registered profile
- **WHEN** an independent peer advertises one registered decode profile
- **THEN** canonical validation accepts that profile without requiring a combined private token
### Requirement: Consumers share one ordered registered-profile intersection
Generated Protocol behavior SHALL select common registered profiles in the first peer's preference order. Provider consumers SHALL separately reject the resulting intersection when it cannot honor the immutable stream policy.
#### Scenario: Policy-compatible profile overlaps
- **WHEN** the gateway advertises HEVC then H.264 and the client advertises only H.264
- **THEN** the shared intersection selects `h264-opus`
#### Scenario: No policy-compatible profile overlaps
- **WHEN** peers have no registered common profile
- **THEN** the shared intersection rejects admission without inventing a private combined token
@@ -0,0 +1,13 @@
## 1. Contract
- [x] 1.1 Add bounded effective stream policy to ProviderSessionWork
- [x] 1.2 Add Go, Rust, and Swift conformance coverage
- [x] 1.3 Regenerate bindings and prove deterministic output
- [x] 1.4 Replace the opaque decode token with an ordered unique set of registered profiles
- [x] 1.5 Generate and cross-check canonical ordered registered-profile intersection behavior
## 2. Consumer Boundary
- [x] 2.1 Verify local Server and Data Plane consumers through a temporary workspace
- [x] 2.2 Publish one new never-reused immutable Protocol version under separate authorization
- [x] 2.3 Resolve from empty caches and pin exact checksums in both consumers
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-30
@@ -0,0 +1,29 @@
## Context
RC9 datagram-v1 uses one-byte fragment fields and caps a media unit at 16 path-MTU fragments. The reviewed Apollo assembler can recover an encoded frame up to 1,028,152 bytes, so the replacement contract must carry at least that size while rejecting proportional allocation from untrusted header claims.
## Goals / Non-Goals
**Goals:**
- Preserve datagram-v1 byte-for-byte and add explicitly negotiated datagram-v2.
- Keep each QUIC datagram at 1,200 bytes or less.
- Bound one complete encoded unit to 1 MiB, at most 891 fragments, four incomplete units, and a 250 ms reassembly lifetime.
- Define duplicate, reorder, timeout, and malformed behavior for every validator.
**Non-Goals:**
- Provider packet exposure, codec parsing, decode/transcode, retransmission, or a generic transport framework.
## Decisions
- Datagram-v2 widens fragment index and count to unsigned 16-bit network-order fields, making a 23-byte header and 1,177-byte media payload. This is the smallest header change that covers the reviewed provider maximum without unrelated sequences.
- Sequence identifies one complete unit. All fragments must agree on channel, sequence, timestamp, count, and flags.
- Reassembly stores only received fragment bytes, rejects conflicting duplicates, permits exact duplicates and bounded reorder, evicts oldest state above four units, and expires state after 250 ms.
- Capability validation registers `datagram-v1` and `datagram-v2`; exact profile equality keeps downgrade behavior fail closed.
## Risks / Trade-offs
- [More fragments increase per-frame overhead] → retain the 1,200-byte PMTU envelope and the 1 MiB frame ceiling.
- [Four concurrent 1 MiB units can consume bounded memory] → allocate only received bytes and evict/expire incomplete state.
- [RC9 peers cannot validate datagram-v2] → require a new immutable Protocol release and exact consumer pins; never mutate RC9.
@@ -0,0 +1,24 @@
## Why
The RC9 datagram contract limits one encoded media unit to 18,864 bytes, below the bounded complete frames required by the reviewed Apollo adapter and the fixed Phase 3C profiles. A new immutable Protocol release must define complete-frame transport and reassembly bounds before consumers can relay realistic encoded frames without mutation.
## What Changes
- Add a versioned PMTU-safe media datagram header with wider fragment fields.
- Bound complete encoded frames, fragments, reassembly state, duplicates, reorder, timeout, and allocation.
- Preserve RC9 datagram-v1 unchanged and require explicit negotiation of the new framing profile.
- Add fixed Python, Go, Rust, and Swift conformance vectors for valid and malformed framing.
## Capabilities
### New Capabilities
- `gateway-media-framing`: Versioned complete encoded-frame fragmentation and bounded reassembly contract.
### Modified Capabilities
None.
## Impact
Protocol framing documentation, registries, capability schema, fixed conformance fixtures, validators, generated Go/Rust/Swift bindings, and immutable consumer versioning. Requirements: SYS-002, SYS-003, P3C-006, P3C-007, P3C-008, P3C-030, P3C-038, VER-001, VER-002, VER-028.
@@ -0,0 +1,34 @@
## ADDED Requirements
### Requirement: Versioned complete encoded-frame datagrams
The Protocol SHALL register `datagram-v2` with a 23-byte network-order header containing magic, version, channel, flags, sequence, timestamp, unsigned 16-bit fragment index, unsigned 16-bit fragment count, and unsigned 16-bit payload length. Media datagrams MUST remain at most 1,200 bytes with at most 1,177 payload bytes, and one sequence MUST represent exactly one complete encoded unit of at most 1,048,576 bytes and 891 fragments.
#### Scenario: Bounded large encoded frame
- **WHEN** an encoded media frame is larger than the datagram-v1 18,864-byte limit but no larger than 1,048,576 bytes
- **THEN** datagram-v2 carries it under one sequence in at most 891 independently bounded fragments
#### Scenario: Oversized encoded frame
- **WHEN** a sender attempts more than 1,048,576 complete bytes or 891 fragments
- **THEN** framing rejects the unit before sending or allocating proportional state
### Requirement: Bounded complete-frame reassembly
Datagram-v2 reassembly SHALL retain at most four incomplete media units and only the bytes actually received, permit bounded fragment reorder and exact duplicate fragments, reject conflicting duplicates or inconsistent metadata, expire incomplete state after 250 milliseconds, and emit a payload only after every fragment is present exactly once.
#### Scenario: Reordered frame completes
- **WHEN** every valid fragment for one unit arrives out of order within the reassembly bounds
- **THEN** the receiver emits exactly one byte-identical complete encoded frame with its original boundary
#### Scenario: Malformed or stale reassembly
- **WHEN** fragments conflict, metadata changes, an index or count is invalid, state exceeds four incomplete units, or a unit exceeds its timeout
- **THEN** the receiver rejects or evicts that incomplete unit without proportional allocation or payload emission
### Requirement: Explicit framing negotiation
`CapabilityProfile.framing` SHALL accept only registered `datagram-v1` or `datagram-v2` values. A peer MUST use datagram-v2 only after exact capability intersection and MUST NOT reinterpret datagram-v1 bytes as datagram-v2.
#### Scenario: Independent datagram-v2 peers overlap
- **WHEN** both peers advertise the registered datagram-v2 framing profile
- **THEN** generated Go, Rust, and Swift validation and fixed conformance accept the profile
#### Scenario: Unknown or downgraded framing
- **WHEN** a peer advertises an unknown framing value or the peers advertise different versions
- **THEN** capability validation or intersection rejects the session before media forwarding
@@ -0,0 +1,20 @@
## 1. Red conformance
- [x] 1.1 Add fixed datagram-v2 and capability vectors that fail current Python, Go, Rust, and Swift validators
- [x] 1.2 Prove unknown version, malformed length, invalid fragment, and oversized complete-unit cases remain rejected
## 2. Contract implementation
- [x] 2.1 Add the versioned datagram-v2 frame grammar and registered framing values
- [x] 2.2 Update generator/native validators and regenerate Go, Rust, and Swift bindings normally
## 3. Verification
- [x] 3.1 Run focused cross-language conformance and strict OpenSpec validation
- [x] 3.2 Run complete `make verify` and prove a second generation has no drift
## 4. Immutable boundary
- [x] 4.1 Publish one new never-reused immutable Protocol version, resolve it
from separate empty consumer caches, and pin the fetched checksums in both
consumers
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-10
@@ -0,0 +1,54 @@
## Context
The JSON control schema is the generated-binding authority for broker requests
and manifests, while VGI1 is the provider-neutral input payload. Both contracts
are strict: old decoders reject unknown response fields and old gateways reject
unknown VGI kinds. Phase 3D therefore needs optional fields plus explicit
feature negotiation rather than a wire-version or protobuf change.
## Goals / Non-Goals
**Goals:**
- Preserve legacy JSON shapes when display negotiation is absent.
- Generate optional referenced objects correctly in Go, Rust, and Swift.
- Define exact, bounded, cross-language absolute-pointer and scroll bytes.
**Non-Goals:**
- Server clamp policy, database persistence, or provider translation.
- Live display renegotiation, provider packet exposure, or protobuf changes.
## Decisions
- Keep control wire version 1 and gate additions with exact feature IDs. This
avoids changing every legacy request while allowing strict clients to demand
the accepted display field.
- Use one reusable `DisplayMode` with the existing provider-policy dimension
bounds. Optional referenced objects become Go pointers so `omitempty` is real;
Rust and Swift retain their existing optional generation.
- Encode absolute pointer as four big-endian u16 values and scroll as two
big-endian i16 values in VGI1. Viewport coordinates are self-contained and
provider-neutral; provider scaling stays outside Protocol.
- Leave protobuf unchanged because the observed broker and VGI consumers use
JSON and fixed byte frames, not generated protobuf messages.
## Risks / Trade-offs
- [Old strict consumers reject new response fields] → emit them only after
`display.request.v1` negotiation.
- [New VGI kinds reach an old or unadvertised gateway] → reject before provider
translation unless the matching input feature is active.
- [Generated Go optional values serialize as zero objects] → require pointers,
omission tests, and clean second generation.
## Migration Plan
Freeze a new never-reused Protocol RC after full verification. Server and Data
Plane then pin that exact tag together. Legacy sessions omit all new fields and
continue using existing input kinds.
## Open Questions
None at the Protocol boundary; policy clamp and Apollo translation remain
consumer-owned work.
@@ -0,0 +1,38 @@
## Why
The Phase 3D native client must request a bounded display mode and send
provider-neutral absolute-pointer and high-resolution scroll input. The current
wire contract exposes only policy-selected stream dimensions and rejects both
new input kinds.
## What Changes
- Add one bounded `DisplayMode` object and optional display fields to session
request/session/manifest contracts while keeping control wire version 1.
- Register `display.request.v1`, `input.absolute.v1`, and `input.scroll.v1` so
every new behavior is explicitly negotiated.
- Extend VGI1 with exact absolute-pointer and signed scroll bodies while
preserving all existing kind encodings.
- Require a new immutable Protocol release candidate after deterministic
Go/Rust/Swift generation and conformance pass; existing tags remain unchanged.
## Capabilities
### New Capabilities
- `session-display-mode`: Optional feature-gated requested and effective display
mode disclosure for native session creation and manifests.
### Modified Capabilities
- `gateway-input-feedback`: Add negotiated provider-neutral absolute-pointer and
high-resolution scroll kinds to the existing sequenced input grammar.
## Impact
- JSON Schema, feature registry, VGI frame documentation, fixed conformance
fixtures, generated Go/Rust/Swift bindings, and strict validators.
- Connection Server session persistence/allocation and Data Plane input
translation after consumers pin the immutable Protocol candidate.
- Protobuf remains unchanged because no observed consumer uses it for these
JSON control or VGI payload paths.
@@ -0,0 +1,33 @@
## MODIFIED 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, controller-state,
absolute-mouse, or high-resolution-scroll event. Absolute mouse kind `0x06`
SHALL contain big-endian u16 x, y, viewport width, and viewport height, reject
zero viewports and coordinates outside the viewport, and require negotiated
`input.absolute.v1`. Scroll kind `0x07` SHALL contain big-endian signed i16
vertical and horizontal deltas and require negotiated `input.scroll.v1`. False
keyboard/mouse state and zeroed controller state are explicit releases.
Unknown or unadvertised 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: Absolute pointer and scroll
- **WHEN** an advertised client sends an in-viewport absolute coordinate or
signed scroll delta with the exact body length
- **THEN** the gateway accepts the provider-neutral event without adding it to
pressed-state cleanup.
#### Scenario: Invalid input envelope
- **WHEN** a client sends an unknown or unadvertised kind, invalid length,
malformed UTF-8 scalar, zero viewport, out-of-range coordinate, or nonzero
reserved field
- **THEN** the gateway rejects it without sending provider input or changing
pressed state.
@@ -0,0 +1,35 @@
## ADDED Requirements
### Requirement: Bounded display mode contract
The Protocol SHALL define `DisplayMode` with required integer
`resolution_width` from 320 through 16384, `resolution_height` from 200 through
8640, and `fps` from 1 through 240, rejecting missing, unknown, or out-of-range
fields.
#### Scenario: Valid display mode
- **WHEN** a client encodes a 2560 by 1440 display mode at 120 FPS
- **THEN** generated Go, Rust, and Swift bindings accept the same exact values.
#### Scenario: Invalid display mode
- **WHEN** any dimension or FPS is outside its bound or an unknown field exists
- **THEN** strict Protocol validation rejects the object.
### Requirement: Feature-gated optional display disclosure
`SessionRequest.requested_display_mode`,
`BrokerSession.requested_display_mode`,
`BrokerSession.effective_display_mode`, and `ManifestProfile.display_mode`
SHALL be optional references to `DisplayMode`. Go bindings SHALL use pointers
with JSON omission and Rust/Swift bindings SHALL use optionals. A client MUST
send the request only after `display.request.v1` negotiation, and a
display-aware manifest MUST contain the accepted mode. Legacy requests SHALL
produce legacy response shapes without these fields.
#### Scenario: Legacy request remains unchanged
- **WHEN** a client omits `requested_display_mode`
- **THEN** encoding omits the field and compatible Server responses omit all
display-mode fields.
#### Scenario: Display-aware request discloses acceptance
- **WHEN** a negotiated client sends a valid requested mode
- **THEN** session responses preserve the requested mode and the allocated
manifest includes the Server-accepted mode.
@@ -0,0 +1,17 @@
## 1. Contract sources
- [x] 1.1 Add failing Go omission/bounds tests and fixed absolute/scroll vectors.
- [x] 1.2 Add the bounded display schema, optional generated references, and feature registrations.
- [x] 1.3 Extend the VGI frame grammar and Python/Go/Rust/Swift classifiers without changing existing kinds.
## 2. Deterministic verification
- [x] 2.1 Regenerate Go/Rust/Swift bindings and pass focused Go, frame, source, binding, and strict-contract checks.
- [x] 2.2 Run the complete Protocol verifier with isolated compiler caches.
- [x] 2.3 Regenerate a second time, confirm zero drift, and inspect the exact source/generated diff.
## 3. Freeze
- [x] 3.1 Commit the verified source and generated bindings as one unsigned capability-sized change.
- [x] 3.2 Verify the next Phase 3D Protocol RC name is unused locally and remotely, then create one local immutable candidate tag without publishing it.
- [x] 3.3 Reconcile and archive this OpenSpec change after the immutable candidate is frozen.
@@ -0,0 +1,4 @@
schema: spec-driven
created: 2026-08-12
goal: Split client-facing session authority from the provider-bearing
Server-to-gateway authority for the coordinated RC4 hard cut.
@@ -0,0 +1,41 @@
## Context
RC3 uses one provider-bearing `SessionAuthority` for both the authenticated Server-to-gateway control plane and the gateway-to-client acknowledgement. Provider profile and identity are valid inputs to gateway provider work and release, but they are forbidden at the client boundary. Existing strict RC3 clients require those fields, so changing the client shape is intentionally incompatible.
## Goals / Non-Goals
**Goals:**
- Make provider disclosure structurally impossible in the client-facing authority type.
- Preserve the provider-bound Server-to-gateway admission, work, release, and cleanup contract.
- Produce strict, matching JSON Schema, Protobuf, Go, Rust, and Swift contracts.
**Non-Goals:**
- Supporting mixed RC3/RC4 gateway and client pairings.
- Changing `SessionAuthority`, `ProviderSessionWork`, `VERSION`, or global compatibility history.
- Adding response negotiation, optional provider fields, or permissive decoding.
## Decisions
1. Add `ClientSessionAuthority` with exactly `version`, `session_id`, `gateway_id`, `audience`, `reconnect_sequence`, `expires_at`, and `capabilities`. Reusing the common validation bounds keeps the new acknowledgement session-bound without representing provider data.
2. Keep the existing provider-bearing `SessionAuthority` unchanged for Server-to-gateway operations. Deleting its provider fields would broaden the security-sensitive change into Server admission and provider-work validation.
3. Treat RC4 as a coordinated hard cut. A dual decoder would still accept the forbidden RC3 shape and is unnecessary for an unreleased candidate.
4. Use the existing generator unchanged. The JSON Schema definition is sufficient to generate strict Go, Rust, and Swift types; the matching Protobuf message uses fields 1 through 7.
## Risks / Trade-offs
- [RC3 and RC4 clients are not wire-compatible] → Pin and qualify Server, gateway, and client as one exact RC4 set; retain RC3 as an immutable rollback set.
- [A future gateway could serialize the wrong authority type] → Consumer gateway tests must capture the raw acknowledgement and require `ClientSessionAuthority` with no provider-bearing keys.
- [Strict decoding rejects future additive fields] → Version a future client authority explicitly instead of weakening this v1 decoder.
## Migration Plan
1. Publish the verified immutable Protocol RC4 tag.
2. Repin Data, Server, and macOS to the exact RC4 commit.
3. Change gateway egress and client decoders together, then qualify the exact all-RC4 set.
4. Roll back only as the complete immutable RC3 set; do not retag or mix candidates.
## Open Questions
None for this pre-release hard cut. Evidence of deployed RC3 coexistence would require a separate negotiated-version design and blocks this migration model.
@@ -0,0 +1,23 @@
## Why
The gateway currently serializes the provider-bearing Server-to-gateway `SessionAuthority` to clients, crossing provider identity into a client trust boundary that forbids it. RC4 must make that boundary structural before the pre-release client set is qualified.
## What Changes
- Add a strict provider-free `ClientSessionAuthority` with the seven session, gateway, audience, reconnect, expiry, and capability fields shared with `SessionAuthority`.
- Keep `SessionAuthority` and `ProviderSessionWork` unchanged for the authenticated Server-to-gateway control plane.
- **BREAKING** Replace the gateway-to-client RC3 response shape with `ClientSessionAuthority` as a coordinated RC4 hard cut; no mixed RC3/RC4 compatibility is claimed.
## Capabilities
### New Capabilities
- `gateway-transport-and-admission`: Defines the distinct client-facing authority and its provider-free gateway admission boundary.
### Modified Capabilities
None.
## Impact
Protocol JSON Schema, tunnel Protobuf, generated Go/Rust/Swift bindings, and consumer Protocol pins advance together to `v1.0.0-phase3d-macos-rc.4`. `VERSION`, global compatibility history, and the Server-to-gateway provider authority remain unchanged.
@@ -0,0 +1,30 @@
## ADDED Requirements
### Requirement: Client-facing authority is provider-free
The gateway-to-client acknowledgement SHALL use `ClientSessionAuthority` version `"1"` containing exactly `version`, `session_id`, `gateway_id`, `audience`, `reconnect_sequence`, `expires_at`, and `capabilities`. The contract SHALL reject missing required fields, unknown fields including provider identities and routes, invalid or noncanonical expiry timestamps, and trailing JSON values.
#### Scenario: Gateway acknowledges an admitted client
- **WHEN** provider work succeeds and gateway and client capabilities intersect
- **THEN** the gateway returns a valid `ClientSessionAuthority` containing the selected capabilities and no provider-bearing field
#### Scenario: Client receives provider-bearing authority
- **WHEN** a client authority payload contains `provider_profile`, `provider_identity`, a provider route, or any unknown key
- **THEN** the strict client authority decoder rejects the payload
#### Scenario: Client receives incomplete or malformed authority
- **WHEN** a client authority omits any required binding, has an invalid expiry, or is followed by another JSON value
- **THEN** the strict client authority decoder rejects the payload
### Requirement: Server-to-gateway authority remains provider-bound
The authenticated Server-to-gateway control plane SHALL continue to use the existing provider-bearing `SessionAuthority` for admission, provider work, release, and cleanup. `SessionAuthority` and `ProviderSessionWork` fields and semantics MUST remain unchanged by this change.
#### Scenario: Gateway performs provider work
- **WHEN** the Server admits a gateway session and the gateway requests provider work
- **THEN** the original provider-bearing `SessionAuthority` continues to bind provider work and subsequent release or cleanup
### Requirement: RC4 is a coordinated hard cut
The RC4 gateway and client SHALL use `ClientSessionAuthority`; mixed RC3/RC4 gateway-client compatibility SHALL NOT be claimed. RC4 SHALL NOT add optional provider fields, a dual decoder, or response negotiation for RC3.
#### Scenario: RC4 candidate is qualified
- **WHEN** the Protocol RC4 tag is pinned by Server, gateway, and client
- **THEN** qualification uses only that exact coordinated set
@@ -0,0 +1,10 @@
## 1. Contract and regressions
- [x] 1.1 Add RED-first Go, Swift, Rust, and Protobuf regressions for the strict provider-free authority.
- [x] 1.2 Add the exact seven-field JSON Schema and Protobuf `ClientSessionAuthority` without changing existing authority contracts.
- [x] 1.3 Regenerate Go, Rust, Swift, Protobuf, and manifest outputs using repository tooling.
## 2. Verification
- [x] 2.1 Pass focused Go and generated-contract regressions.
- [x] 2.2 Pass strict OpenSpec validation, full `make verify`, second-generation cleanliness, and diff checks.
@@ -0,0 +1,25 @@
# gateway-heartbeat-telemetry Specification
## Purpose
Define the bounded, low-cardinality gateway observations carried by authenticated heartbeats and their canonical units and exclusions.
## Requirements
### Requirement: Heartbeat carries observed gateway telemetry
Every authenticated `GatewayHeartbeat` SHALL carry the bounded process-level counters, delay totals and samples, control RTT/loss/jitter, pending reliable work, reconnect count, and provider state defined by `GatewayTelemetry`.
#### Scenario: Valid telemetry heartbeat
- **WHEN** a gateway reports its current observed snapshot
- **THEN** Go, Rust, and Swift bindings accept the same bounded low-cardinality values and units
### Requirement: Heartbeat telemetry excludes sensitive dimensions
Heartbeat telemetry MUST reject unknown fields and MUST NOT include session, route, endpoint, credential, label, or payload values.
#### Scenario: Secret or high-cardinality field is attempted
- **WHEN** a heartbeat contains an unregistered session, route, endpoint, credential, or payload field
- **THEN** strict contract validation rejects it before authenticated transport
### Requirement: Delay and egress observations have one canonical meaning
Queue delay SHALL measure provider-queue residence, processing delay SHALL measure active gateway recovery/framing/QUIC work excluding queue and pacing, and pacing delay SHALL measure scheduler waiting only. Processing samples SHALL count complete provider media units rather than Verse fragments. Measured egress SHALL derive from transmitted-byte deltas over monotonic elapsed time and MUST NOT be copied from configured capacity.
#### Scenario: One provider unit becomes multiple Verse frames
- **WHEN** one complete provider unit waits in the queue, traverses gateway processing, waits for pacing, and fragments into multiple Verse frames
- **THEN** each delay total includes only its defined interval and the heartbeat advances processing samples exactly once
@@ -0,0 +1,102 @@
# gateway-input-feedback Specification
## Purpose
Define the provider-neutral typed input, feedback, and text-clipboard envelopes
used across the authenticated Verse gateway boundary.
## 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, controller-state,
absolute-mouse, or high-resolution-scroll event. Absolute mouse kind `0x06`
SHALL contain big-endian u16 x, y, viewport width, and viewport height, reject
zero viewports and coordinates outside the viewport, and require negotiated
`input.absolute.v1`. Scroll kind `0x07` SHALL contain big-endian signed i16
vertical and horizontal deltas and require negotiated `input.scroll.v1`. False
keyboard/mouse state and zeroed controller state are explicit releases.
Unknown or unadvertised 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: Absolute pointer and scroll
- **WHEN** an advertised client sends an in-viewport absolute coordinate or
signed scroll delta with the exact body length
- **THEN** the gateway accepts the provider-neutral event without adding it to
pressed-state cleanup.
#### Scenario: Invalid input envelope
- **WHEN** a client sends an unknown or unadvertised kind, invalid length,
malformed UTF-8 scalar, zero viewport, out-of-range coordinate, 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, FEC/loss feedback, and client-direction type
`0x03` with an empty payload as terminal receipt SHALL be valid from the client
to the gateway. The fixed conformance corpus and every generated or native
Protocol validator SHALL accept that exact receipt and reject unknown types,
wrong direction, nonempty receipt bodies, truncation, and length mismatch. The
terminal receipt SHALL be valid only while the same session awaits receipt of
its one terminal event and MUST NOT be forwarded to the provider. 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: Terminal event receipt
- **WHEN** a client receives the reliable typed terminal event
- **THEN** it sends the fixed empty client-direction type `0x03` receipt and the gateway owns bounded tunnel closure without forwarding the receipt to the provider
#### Scenario: Unauthorized or malformed feedback
- **WHEN** feedback is disabled by policy, has an invalid direction/type/length, contains a forbidden provider field, or sends a terminal receipt outside the awaiting-terminal state
- **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,37 @@
# gateway-media-framing Specification
## Purpose
Define the versioned datagram framing, bounded reassembly, and explicit negotiation required to carry complete encoded media units.
## Requirements
### Requirement: Versioned complete encoded-frame datagrams
The Protocol SHALL register `datagram-v2` with a 23-byte network-order header containing magic, version, channel, flags, sequence, timestamp, unsigned 16-bit fragment index, unsigned 16-bit fragment count, and unsigned 16-bit payload length. Media datagrams MUST remain at most 1,200 bytes with at most 1,177 payload bytes, and one sequence MUST represent exactly one complete encoded unit of at most 1,048,576 bytes and 891 fragments.
#### Scenario: Bounded large encoded frame
- **WHEN** an encoded media frame is larger than the datagram-v1 18,864-byte limit but no larger than 1,048,576 bytes
- **THEN** datagram-v2 carries it under one sequence in at most 891 independently bounded fragments
#### Scenario: Oversized encoded frame
- **WHEN** a sender attempts more than 1,048,576 complete bytes or 891 fragments
- **THEN** framing rejects the unit before sending or allocating proportional state
### Requirement: Bounded complete-frame reassembly
Datagram-v2 reassembly SHALL retain at most four incomplete media units and only the bytes actually received, permit bounded fragment reorder and exact duplicate fragments, reject conflicting duplicates or inconsistent metadata, expire incomplete state after 250 milliseconds, and emit a payload only after every fragment is present exactly once.
#### Scenario: Reordered frame completes
- **WHEN** every valid fragment for one unit arrives out of order within the reassembly bounds
- **THEN** the receiver emits exactly one byte-identical complete encoded frame with its original boundary
#### Scenario: Malformed or stale reassembly
- **WHEN** fragments conflict, metadata changes, an index or count is invalid, state exceeds four incomplete units, or a unit exceeds its timeout
- **THEN** the receiver rejects or evicts that incomplete unit without proportional allocation or payload emission
### Requirement: Explicit framing negotiation
`CapabilityProfile.framing` SHALL accept only registered `datagram-v1` or `datagram-v2` values. A peer MUST use datagram-v2 only after exact capability intersection and MUST NOT reinterpret datagram-v1 bytes as datagram-v2.
#### Scenario: Independent datagram-v2 peers overlap
- **WHEN** both peers advertise the registered datagram-v2 framing profile
- **THEN** generated Go, Rust, and Swift validation and fixed conformance accept the profile
#### Scenario: Unknown or downgraded framing
- **WHEN** a peer advertises an unknown framing value or the peers advertise different versions
- **THEN** capability validation or intersection rejects the session before media forwarding
@@ -0,0 +1,56 @@
# native-session-tunnel-credentials Specification
## Purpose
Define server-owned native session identity and short-lived tunnel credential disclosure without accepting client-owned policy.
## Requirements
### Requirement: Client-owned allocation policy is removed by a coordinated wire-version cutover
Control wire version `2` SHALL be current, wire version `1` SHALL be n-1, wire version `0` SHALL be n-2, and `control.v2` SHALL identify the current control feature. The wire-version-2 `SessionRequest` MUST reject `policy_snapshot`; `BrokerSession` SHALL retain the Server-owned policy snapshot disclosure. This removal is intentionally incompatible with the wire-version-1 request shape and SHALL require coordinated Protocol, Server, Data Plane, and client pin updates. Declaring wire version `1` as n-1 does not permit a wire-version-1 request body to be decoded as wire version `2`.
#### Scenario: Client attempts to supply policy
- **WHEN** a session request contains `policy_snapshot`
- **THEN** strict Protocol decoding rejects the unknown field
#### Scenario: A legacy client presents a wire-version-1 request shape
- **WHEN** a client uses the removed client-owned policy field or does not negotiate `control.v2`
- **THEN** the wire-version-2 broker request path rejects the request instead of silently interpreting it as compatible
### Requirement: Authentication response shapes are route-specific
Browser login and browser session responses SHALL use `BrowserAuthenticatedSession`, which forbids `native_identity`. Native bearer session responses SHALL use `NativeAuthenticatedSession`, which requires a `NativeSessionIdentity` containing both `client_device_id` and `device_key_id`. Both response types SHALL require every `roles` item to contain 1 through 64 UTF-8 bytes.
#### Scenario: Native identity is incomplete
- **WHEN** a native session response omits either identity field
- **THEN** strict Protocol decoding rejects the nested object
#### Scenario: Browser response leaks native identity
- **WHEN** a browser login or browser session response contains `native_identity`
- **THEN** strict Protocol decoding rejects the response
#### Scenario: Authentication response has a malformed role
- **WHEN** any `roles` item is empty or exceeds 64 UTF-8 bytes
- **THEN** every generated binding rejects the response
### Requirement: Native tunnel credentials are non-cacheable
`POST /api/v1/auth/tunnel-credentials` SHALL accept only native bearer authentication, no request body, and return a bounded `NativeTunnelCredential` with `Cache-Control: no-store`. `NativeTunnelCredential.expires_at` SHALL be canonical RFC 3339 UTC in every generated binding: an uppercase `T`, a `Z` suffix, and either no fractional seconds or a 1-through-9-digit non-zero-terminated fractional component.
#### Scenario: Native client requests tunnel credentials
- **WHEN** an authenticated native client calls the endpoint
- **THEN** the response identifies the client device and key, supplies certificate and trust PEM, gives an expiry, and cannot be stored
#### Scenario: Native tunnel credential uses a noncanonical expiry
- **WHEN** `expires_at` uses a numeric offset or a noncanonical fractional representation
- **THEN** every generated binding rejects the credential
### Requirement: Swift bindings support the macOS client deployment floor
The generated Swift package SHALL declare macOS 14 as its minimum supported macOS platform.
#### Scenario: Native client consumes the Swift package
- **WHEN** the package manifest is resolved for the Phase 3D macOS client
- **THEN** its platform declaration permits macOS 14 and later
### Requirement: Manifest public identity is the TLS server name
`ConnectionManifest.gateway.public_identity` SHALL be the exact TLS server name and SHALL remain distinct from dial addresses, gateway UUIDs, certificate fingerprints, and provider identities.
#### Scenario: Client connects to a gateway address
- **WHEN** a client dials any advertised gateway address
- **THEN** it verifies TLS against `gateway.public_identity`
@@ -0,0 +1,36 @@
# provider-stream-policy Specification
## Purpose
Define immutable provider stream-policy fields and the registered ordered decode-profile intersection shared by all generated bindings.
## Requirements
### Requirement: Provider work carries the effective stream policy
Authenticated `ProviderSessionWork` SHALL carry the immutable policy version and its effective resolution, frame rate, codec, target bitrate, and audio-enabled decision.
#### Scenario: Gateway receives an effective policy
- **WHEN** the Server issues provider work for an admitted session
- **THEN** the work identifies the policy version and includes the effective bounded stream-policy values
### Requirement: Stream-policy bindings share one strict contract
Generated Go, Rust, and Swift bindings MUST reject missing, unknown, out-of-range, or unsupported stream-policy wire values according to the canonical schema.
#### Scenario: Invalid policy is rejected consistently
- **WHEN** provider work contains an unknown codec or a value outside the canonical bounds
- **THEN** every generated binding rejects the work before it can reach provider setup
### Requirement: Decode capabilities use registered ordered profiles
`CapabilityProfile.client_decode` SHALL be a non-empty ordered unique set containing only registered `h264-opus` and `hevc-opus` profile identifiers. It MUST NOT encode multiple capabilities in an opaque private token.
#### Scenario: Independent peer advertises one registered profile
- **WHEN** an independent peer advertises one registered decode profile
- **THEN** canonical validation accepts that profile without requiring a combined private token
### Requirement: Consumers share one ordered registered-profile intersection
Generated Protocol behavior SHALL select common registered profiles in the first peer's preference order. Provider consumers SHALL separately reject the resulting intersection when it cannot honor the immutable stream policy.
#### Scenario: Policy-compatible profile overlaps
- **WHEN** the gateway advertises HEVC then H.264 and the client advertises only H.264
- **THEN** the shared intersection selects `h264-opus`
#### Scenario: No policy-compatible profile overlaps
- **WHEN** peers have no registered common profile
- **THEN** the shared intersection rejects admission without inventing a private combined token
@@ -0,0 +1,38 @@
# session-display-mode Specification
## Purpose
TBD - created by archiving change phase-3d-client-display-input. Update Purpose after archive.
## Requirements
### Requirement: Bounded display mode contract
The Protocol SHALL define `DisplayMode` with required integer
`resolution_width` from 320 through 16384, `resolution_height` from 200 through
8640, and `fps` from 1 through 240, rejecting missing, unknown, or out-of-range
fields.
#### Scenario: Valid display mode
- **WHEN** a client encodes a 2560 by 1440 display mode at 120 FPS
- **THEN** generated Go, Rust, and Swift bindings accept the same exact values.
#### Scenario: Invalid display mode
- **WHEN** any dimension or FPS is outside its bound or an unknown field exists
- **THEN** strict Protocol validation rejects the object.
### Requirement: Feature-gated optional display disclosure
`SessionRequest.requested_display_mode`,
`BrokerSession.requested_display_mode`,
`BrokerSession.effective_display_mode`, and `ManifestProfile.display_mode`
SHALL be optional references to `DisplayMode`. Go bindings SHALL use pointers
with JSON omission and Rust/Swift bindings SHALL use optionals. A client MUST
send the request only after `display.request.v1` negotiation, and a
display-aware manifest MUST contain the accepted mode. Legacy requests SHALL
produce legacy response shapes without these fields.
#### Scenario: Legacy request remains unchanged
- **WHEN** a client omits `requested_display_mode`
- **THEN** encoding omits the field and compatible Server responses omit all
display-mode fields.
#### Scenario: Display-aware request discloses acceptance
- **WHEN** a negotiated client sends a valid requested mode
- **THEN** session responses preserve the requested mode and the allocated
manifest includes the Server-accepted mode.
+7
View File
@@ -32,3 +32,10 @@ message ClipboardText {
string text = 1; string text = 1;
string encoding = 2; string encoding = 2;
} }
message GatewayClipboardText {
string direction = 1;
string text = 2;
string encoding = 3;
string loop_token = 4;
}
+141
View File
@@ -49,3 +49,144 @@ message ChannelOpen {
string direction = 2; string direction = 2;
uint32 maximum_frame_bytes = 3; uint32 maximum_frame_bytes = 3;
} }
message CapabilityProfile {
string transport = 1;
string framing = 2;
string media = 3;
string audio = 4;
string source_rate_control = 5;
repeated string client_decode = 6;
}
message GatewayRegistration {
string version = 1;
string gateway_id = 2;
string instance_identity = 3;
string certificate_identity = 4;
string public_identity = 5;
string address = 6;
uint32 protocol_min_version = 7;
uint32 protocol_max_version = 8;
uint32 connection_capacity = 9;
uint64 bandwidth_capacity_kbps = 10;
repeated string features = 11;
CapabilityProfile capabilities = 12;
string provider_identity = 13;
}
message GatewayHeartbeat {
string version = 1;
string gateway_id = 2;
uint64 sequence = 3;
google.protobuf.Timestamp observed_at = 4;
uint32 active_connections = 5;
uint64 egress_kbps = 6;
string state = 7;
}
message GatewayDrain {
string version = 1;
string gateway_id = 2;
uint64 sequence = 3;
string reason = 4;
google.protobuf.Timestamp deadline = 5;
}
message TunnelAdmissionRequest {
string version = 1;
string session_id = 2;
string gateway_id = 3;
string audience = 4;
string grant = 5;
uint64 reconnect_sequence = 6;
string client_nonce = 7;
CapabilityProfile capabilities = 8;
string device_signature = 9;
}
message SessionAuthority {
string version = 1;
string session_id = 2;
string gateway_id = 3;
string audience = 4;
uint64 reconnect_sequence = 5;
google.protobuf.Timestamp expires_at = 6;
CapabilityProfile capabilities = 7;
string provider_profile = 8;
string provider_identity = 9;
}
message ClientSessionAuthority {
string version = 1;
string session_id = 2;
string gateway_id = 3;
string audience = 4;
uint64 reconnect_sequence = 5;
google.protobuf.Timestamp expires_at = 6;
CapabilityProfile capabilities = 7;
}
message ProviderSessionWork {
string version = 1;
string session_id = 2;
string gateway_id = 3;
uint64 reconnect_sequence = 4;
google.protobuf.Timestamp expires_at = 5;
string provider_profile = 6;
string provider_identity = 7;
string policy_version_id = 8;
string application_id = 9;
string management_host = 10;
uint32 management_port = 11;
string stream_host = 12;
uint32 stream_port = 13;
string client_certificate_pem = 14;
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 {
string version = 1;
string flow_id = 2;
uint64 sequence = 3;
uint32 flags = 4;
uint32 fragment_index = 5;
uint32 fragment_count = 6;
uint64 timestamp_ms = 7;
bytes payload = 8;
}
message ProviderState {
string version = 1;
string session_id = 2;
string state = 3;
bool cleanup_pending = 4;
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;
string message = 3;
bool retryable = 4;
}
+9 -2
View File
@@ -1,9 +1,16 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"max_frame_bytes": 65536, "max_frame_bytes": 65536,
"framing_profiles": [
{"id": "datagram-v1", "header_bytes": 21, "media_payload_bytes": 1179, "max_fragments": 16, "max_complete_bytes": 18864},
{"id": "datagram-v2", "header_bytes": 23, "media_payload_bytes": 1177, "max_fragments": 891, "max_complete_bytes": 1048576}
],
"datagrams": [ "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": "control.cancel.v1", "direction": "client-to-server", "max_payload_bytes": 2048},
{"id": "clipboard.text.v1", "direction": "bidirectional", "max_payload_bytes": 65536} {"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, "payload_profile": "gateway-input-v1"}
] ]
} }
+5 -1
View File
@@ -2,7 +2,11 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"features": [ "features": [
{"id": "control.v1", "direction": "bidirectional", "max_frame_bytes": 65536}, {"id": "control.v1", "direction": "bidirectional", "max_frame_bytes": 65536},
{"id": "control.v2", "direction": "bidirectional", "max_frame_bytes": 65536},
{"id": "events.resume.v1", "direction": "server-to-client", "max_frame_bytes": 65536}, {"id": "events.resume.v1", "direction": "server-to-client", "max_frame_bytes": 65536},
{"id": "clipboard.text.v1", "direction": "bidirectional", "max_frame_bytes": 65536} {"id": "clipboard.text.v1", "direction": "bidirectional", "max_frame_bytes": 65536},
{"id": "display.request.v1", "direction": "client-to-server", "max_frame_bytes": 65536},
{"id": "input.absolute.v1", "direction": "client-to-server", "max_frame_bytes": 14},
{"id": "input.scroll.v1", "direction": "client-to-server", "max_frame_bytes": 10}
] ]
} }
+317 -5
View File
@@ -178,16 +178,70 @@
"refresh_expires_at": {"type": "string", "format": "date-time", "maxLength": 64} "refresh_expires_at": {"type": "string", "format": "date-time", "maxLength": 64}
} }
}, },
"NativeSessionIdentity": {
"type": "object",
"additionalProperties": false,
"required": ["client_device_id", "device_key_id"],
"properties": {
"client_device_id": {"type": "string", "minLength": 1, "maxLength": 128},
"device_key_id": {"type": "string", "minLength": 1, "maxLength": 128}
}
},
"BrowserAuthenticatedSession": {
"type": "object",
"additionalProperties": false,
"required": ["username", "provider", "roles", "role"],
"properties": {
"username": {"type": "string", "minLength": 1, "maxLength": 256},
"provider": {"type": "string", "minLength": 1, "maxLength": 64},
"roles": {"type": "array", "maxItems": 16, "items": {"type": "string", "minLength": 1, "maxLength": 64, "x-max-bytes": 64}},
"role": {"type": "string", "enum": ["user", "admin"]}
}
},
"NativeAuthenticatedSession": {
"type": "object",
"additionalProperties": false,
"required": ["username", "provider", "roles", "role", "native_identity"],
"properties": {
"username": {"type": "string", "minLength": 1, "maxLength": 256},
"provider": {"type": "string", "minLength": 1, "maxLength": 64},
"roles": {"type": "array", "maxItems": 16, "items": {"type": "string", "minLength": 1, "maxLength": 64, "x-max-bytes": 64}},
"role": {"type": "string", "enum": ["user", "admin"]},
"native_identity": {"$ref": "#/$defs/NativeSessionIdentity"}
}
},
"NativeTunnelCredential": {
"type": "object",
"additionalProperties": false,
"required": ["client_device_id", "device_key_id", "certificate_chain_pem", "trust_bundle_pem", "expires_at"],
"properties": {
"client_device_id": {"type": "string", "minLength": 1, "maxLength": 128},
"device_key_id": {"type": "string", "minLength": 1, "maxLength": 128},
"certificate_chain_pem": {"type": "string", "minLength": 1, "maxLength": 65536},
"trust_bundle_pem": {"type": "string", "minLength": 1, "maxLength": 65536},
"expires_at": {"type": "string", "format": "date-time", "maxLength": 64}
}
},
"DisplayMode": {
"type": "object",
"additionalProperties": false,
"required": ["resolution_width", "resolution_height", "fps"],
"properties": {
"resolution_width": {"type": "integer", "minimum": 320, "maximum": 16384},
"resolution_height": {"type": "integer", "minimum": 200, "maximum": 8640},
"fps": {"type": "integer", "minimum": 1, "maximum": 240}
}
},
"SessionRequest": { "SessionRequest": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["client_device_id", "device_key_id", "pool_id", "idempotency_key", "policy_snapshot"], "required": ["client_device_id", "device_key_id", "pool_id", "idempotency_key"],
"properties": { "properties": {
"client_device_id": {"type": "string", "minLength": 1, "maxLength": 128}, "client_device_id": {"type": "string", "minLength": 1, "maxLength": 128},
"device_key_id": {"type": "string", "minLength": 1, "maxLength": 128}, "device_key_id": {"type": "string", "minLength": 1, "maxLength": 128},
"pool_id": {"type": "string", "minLength": 1, "maxLength": 128}, "pool_id": {"type": "string", "minLength": 1, "maxLength": 128},
"idempotency_key": {"type": "string", "minLength": 1, "maxLength": 256}, "idempotency_key": {"type": "string", "minLength": 1, "maxLength": 256},
"policy_snapshot": {"$ref": "#/$defs/AllocationPolicy"} "requested_display_mode": {"$ref": "#/$defs/DisplayMode"}
} }
}, },
"ReconnectRequest": { "ReconnectRequest": {
@@ -219,7 +273,9 @@
"correlation_id": {"type": "string", "minLength": 1, "maxLength": 128}, "correlation_id": {"type": "string", "minLength": 1, "maxLength": 128},
"requested_at": {"type": "string", "format": "date-time", "maxLength": 64}, "requested_at": {"type": "string", "format": "date-time", "maxLength": 64},
"ended_at": {"type": "string", "format": "date-time", "maxLength": 64}, "ended_at": {"type": "string", "format": "date-time", "maxLength": 64},
"version": {"type": "integer", "minimum": 1} "version": {"type": "integer", "minimum": 1},
"requested_display_mode": {"$ref": "#/$defs/DisplayMode"},
"effective_display_mode": {"$ref": "#/$defs/DisplayMode"}
} }
}, },
"AllocationPolicy": { "AllocationPolicy": {
@@ -261,7 +317,12 @@
"properties": { "properties": {
"id": {"type": "string", "minLength": 1, "maxLength": 128}, "id": {"type": "string", "minLength": 1, "maxLength": 128},
"addresses": {"type": "array", "minItems": 1, "maxItems": 4, "items": {"type": "string", "minLength": 1, "maxLength": 256}}, "addresses": {"type": "array", "minItems": 1, "maxItems": 4, "items": {"type": "string", "minLength": 1, "maxLength": 256}},
"public_identity": {"type": "string", "minLength": 1, "maxLength": 256} "public_identity": {
"type": "string",
"minLength": 1,
"maxLength": 256,
"description": "Exact TLS server name; distinct from dial addresses, gateway UUIDs, certificate fingerprints, and provider identities."
}
} }
}, },
"ManifestTunnel": { "ManifestTunnel": {
@@ -279,7 +340,8 @@
"required": ["id", "bounds"], "required": ["id", "bounds"],
"properties": { "properties": {
"id": {"type": "string", "minLength": 1, "maxLength": 128}, "id": {"type": "string", "minLength": 1, "maxLength": 128},
"bounds": {"$ref": "#/$defs/ManifestBounds"} "bounds": {"$ref": "#/$defs/ManifestBounds"},
"display_mode": {"$ref": "#/$defs/DisplayMode"}
} }
}, },
"ManifestBounds": { "ManifestBounds": {
@@ -335,6 +397,28 @@
"encoding": {"type": "string", "const": "utf-8"} "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": { "VersionNegotiation": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
@@ -343,6 +427,234 @@
"supported_versions": {"type": "array", "minItems": 1, "maxItems": 3, "items": {"type": "string", "maxLength": 16}}, "supported_versions": {"type": "array", "minItems": 1, "maxItems": 3, "items": {"type": "string", "maxLength": 16}},
"features": {"type": "array", "maxItems": 64, "items": {"type": "string", "maxLength": 64}} "features": {"type": "array", "maxItems": 64, "items": {"type": "string", "maxLength": 64}}
} }
},
"CapabilityProfile": {
"type": "object",
"additionalProperties": false,
"required": ["transport", "framing", "media", "audio", "source_rate_control", "client_decode"],
"properties": {
"transport": {"type": "string", "minLength": 1, "maxLength": 64},
"framing": {"type": "string", "enum": ["datagram-v1", "datagram-v2"]},
"media": {"type": "string", "minLength": 1, "maxLength": 64},
"audio": {"type": "string", "minLength": 1, "maxLength": 64},
"source_rate_control": {"type": "string", "minLength": 1, "maxLength": 64},
"client_decode": {
"type": "array",
"minItems": 1,
"maxItems": 2,
"uniqueItems": true,
"items": {"type": "string", "enum": ["h264-opus", "hevc-opus"]}
}
}
},
"GatewayRegistration": {
"type": "object",
"additionalProperties": false,
"required": ["version", "gateway_id", "instance_identity", "certificate_identity", "public_identity", "address", "provider_identity", "protocol_min_version", "protocol_max_version", "connection_capacity", "bandwidth_capacity_kbps", "features", "capabilities"],
"properties": {
"version": {"type": "string", "const": "1"},
"gateway_id": {"type": "string", "minLength": 1, "maxLength": 128},
"instance_identity": {"type": "string", "minLength": 1, "maxLength": 512},
"certificate_identity": {"type": "string", "minLength": 1, "maxLength": 512},
"public_identity": {"type": "string", "minLength": 1, "maxLength": 256},
"address": {"type": "string", "minLength": 1, "maxLength": 256},
"provider_identity": {"type": "string", "minLength": 1, "maxLength": 256},
"protocol_min_version": {"type": "integer", "minimum": 1, "maximum": 100},
"protocol_max_version": {"type": "integer", "minimum": 1, "maximum": 100},
"connection_capacity": {"type": "integer", "minimum": 1, "maximum": 1000000},
"bandwidth_capacity_kbps": {"type": "integer", "minimum": 1, "maximum": 1000000000},
"features": {"type": "array", "maxItems": 64, "items": {"type": "string", "minLength": 1, "maxLength": 64}},
"capabilities": {"$ref": "#/$defs/CapabilityProfile"}
}
},
"GatewayTelemetry": {
"type": "object",
"additionalProperties": false,
"required": ["admitted_sessions", "admission_rejects", "reconnects", "drain_transitions", "media_drops", "media_packets", "media_bytes", "queue_delay_micros", "processing_delay_micros", "processing_samples", "pacing_delay_micros", "provider_errors", "input_rejected", "control_rtt_micros", "control_jitter_micros", "control_loss_ppm", "pending_reliable", "provider_state"],
"properties": {
"admitted_sessions": {"type": "integer", "minimum": 0, "maximum": 9223372036854775807},
"admission_rejects": {"type": "integer", "minimum": 0, "maximum": 9223372036854775807},
"reconnects": {"type": "integer", "minimum": 0, "maximum": 9223372036854775807},
"drain_transitions": {"type": "integer", "minimum": 0, "maximum": 9223372036854775807},
"media_drops": {"type": "integer", "minimum": 0, "maximum": 9223372036854775807},
"media_packets": {"type": "integer", "minimum": 0, "maximum": 9223372036854775807},
"media_bytes": {"type": "integer", "minimum": 0, "maximum": 9223372036854775807},
"queue_delay_micros": {"type": "integer", "minimum": 0, "maximum": 9223372036854775807},
"processing_delay_micros": {"type": "integer", "minimum": 0, "maximum": 9223372036854775807},
"processing_samples": {"type": "integer", "minimum": 0, "maximum": 9223372036854775807},
"pacing_delay_micros": {"type": "integer", "minimum": 0, "maximum": 9223372036854775807},
"provider_errors": {"type": "integer", "minimum": 0, "maximum": 9223372036854775807},
"input_rejected": {"type": "integer", "minimum": 0, "maximum": 9223372036854775807},
"control_rtt_micros": {"type": "integer", "minimum": 0, "maximum": 9223372036854775807},
"control_jitter_micros": {"type": "integer", "minimum": 0, "maximum": 9223372036854775807},
"control_loss_ppm": {"type": "integer", "minimum": 0, "maximum": 1000000},
"pending_reliable": {"type": "integer", "minimum": 0, "maximum": 9223372036854775807},
"provider_state": {"type": "string", "enum": ["unknown", "starting", "ready", "disconnected", "terminating", "terminated", "cleanup_pending", "failed"]}
}
},
"GatewayHeartbeat": {
"type": "object",
"additionalProperties": false,
"required": ["version", "gateway_id", "sequence", "observed_at", "active_connections", "egress_kbps", "state", "telemetry"],
"properties": {
"version": {"type": "string", "const": "1"},
"gateway_id": {"type": "string", "minLength": 1, "maxLength": 128},
"sequence": {"type": "integer", "minimum": 1},
"observed_at": {"type": "string", "format": "date-time", "maxLength": 64},
"active_connections": {"type": "integer", "minimum": 0, "maximum": 1000000},
"egress_kbps": {"type": "integer", "minimum": 0, "maximum": 1000000000},
"state": {"type": "string", "enum": ["ready", "draining", "offline"]},
"telemetry": {"$ref": "#/$defs/GatewayTelemetry"}
}
},
"GatewayDrain": {
"type": "object",
"additionalProperties": false,
"required": ["version", "gateway_id", "sequence", "reason", "deadline"],
"properties": {
"version": {"type": "string", "const": "1"},
"gateway_id": {"type": "string", "minLength": 1, "maxLength": 128},
"sequence": {"type": "integer", "minimum": 1},
"reason": {"type": "string", "minLength": 1, "maxLength": 256},
"deadline": {"type": "string", "format": "date-time", "maxLength": 64}
}
},
"TunnelAdmissionRequest": {
"type": "object",
"additionalProperties": false,
"required": ["version", "session_id", "gateway_id", "audience", "grant", "reconnect_sequence", "client_nonce", "device_signature", "capabilities"],
"properties": {
"version": {"type": "string", "const": "1"},
"session_id": {"type": "string", "minLength": 1, "maxLength": 128},
"gateway_id": {"type": "string", "minLength": 1, "maxLength": 128},
"audience": {"type": "string", "minLength": 1, "maxLength": 256},
"grant": {"type": "string", "minLength": 43, "maxLength": 256},
"reconnect_sequence": {"type": "integer", "minimum": 0},
"client_nonce": {"type": "string", "minLength": 16, "maxLength": 128},
"device_signature": {"type": "string", "minLength": 86, "maxLength": 86},
"capabilities": {"$ref": "#/$defs/CapabilityProfile"}
}
},
"SessionAuthority": {
"type": "object",
"additionalProperties": false,
"required": ["version", "session_id", "gateway_id", "audience", "reconnect_sequence", "expires_at", "capabilities", "provider_profile", "provider_identity"],
"properties": {
"version": {"type": "string", "const": "1"},
"session_id": {"type": "string", "minLength": 1, "maxLength": 128},
"gateway_id": {"type": "string", "minLength": 1, "maxLength": 128},
"audience": {"type": "string", "minLength": 1, "maxLength": 256},
"reconnect_sequence": {"type": "integer", "minimum": 0},
"expires_at": {"type": "string", "format": "date-time", "maxLength": 64},
"capabilities": {"$ref": "#/$defs/CapabilityProfile"},
"provider_profile": {"type": "string", "enum": ["apollo"]},
"provider_identity": {"type": "string", "minLength": 1, "maxLength": 256}
}
},
"ClientSessionAuthority": {
"type": "object",
"additionalProperties": false,
"required": ["version", "session_id", "gateway_id", "audience", "reconnect_sequence", "expires_at", "capabilities"],
"properties": {
"version": {"type": "string", "const": "1"},
"session_id": {"type": "string", "minLength": 1, "maxLength": 128},
"gateway_id": {"type": "string", "minLength": 1, "maxLength": 128},
"audience": {"type": "string", "minLength": 1, "maxLength": 256},
"reconnect_sequence": {"type": "integer", "minimum": 0},
"expires_at": {"type": "string", "format": "date-time", "maxLength": 64},
"capabilities": {"$ref": "#/$defs/CapabilityProfile"}
}
},
"ProviderStreamPolicy": {
"type": "object",
"additionalProperties": false,
"required": ["resolution_width", "resolution_height", "fps", "codec", "bitrate_kbps", "audio_enabled"],
"properties": {
"resolution_width": {"type": "integer", "minimum": 320, "maximum": 16384},
"resolution_height": {"type": "integer", "minimum": 200, "maximum": 8640},
"fps": {"type": "integer", "minimum": 1, "maximum": 240},
"codec": {"type": "string", "enum": ["H264", "HEVC", "AV1"]},
"bitrate_kbps": {"type": "integer", "minimum": 100, "maximum": 1000000},
"audio_enabled": {"type": "boolean"}
}
},
"ProviderSessionWork": {
"type": "object",
"additionalProperties": false,
"required": ["version", "session_id", "gateway_id", "reconnect_sequence", "expires_at", "provider_profile", "provider_identity", "policy_version_id", "stream_policy", "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},
"gateway_id": {"type": "string", "minLength": 1, "maxLength": 128},
"reconnect_sequence": {"type": "integer", "minimum": 0},
"expires_at": {"type": "string", "format": "date-time", "maxLength": 64},
"provider_profile": {"type": "string", "const": "apollo"},
"provider_identity": {"type": "string", "minLength": 1, "maxLength": 256},
"policy_version_id": {"type": "string", "minLength": 1, "maxLength": 128},
"stream_policy": {"$ref": "#/$defs/ProviderStreamPolicy"},
"application_id": {"type": "string", "minLength": 1, "maxLength": 128},
"client_id": {"type": "string", "minLength": 1, "maxLength": 128},
"management_host": {"type": "string", "minLength": 1, "maxLength": 256},
"management_port": {"type": "integer", "minimum": 1, "maximum": 65535},
"stream_host": {"type": "string", "minLength": 1, "maxLength": 256},
"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},
"clipboard_policy": {"$ref": "#/$defs/ClipboardPolicy"},
"provider_application_termination_allowed": {"type": "boolean"}
}
},
"ChannelFrame": {
"type": "object",
"additionalProperties": false,
"required": ["version", "flow_id", "sequence", "flags", "fragment_index", "fragment_count", "timestamp_ms", "payload"],
"properties": {
"version": {"type": "string", "const": "1"},
"flow_id": {"type": "string", "minLength": 1, "maxLength": 64},
"sequence": {"type": "integer", "minimum": 0},
"flags": {"type": "integer", "minimum": 0, "maximum": 255},
"fragment_index": {"type": "integer", "minimum": 0, "maximum": 15},
"fragment_count": {"type": "integer", "minimum": 1, "maximum": 16},
"timestamp_ms": {"type": "integer", "minimum": 0},
"payload": {"type": "string", "maxLength": 87384, "x-max-bytes": 65536}
}
},
"ProviderState": {
"type": "object",
"additionalProperties": false,
"required": ["version", "session_id", "state", "cleanup_pending", "channels"],
"properties": {
"version": {"type": "string", "const": "1"},
"session_id": {"type": "string", "minLength": 1, "maxLength": 128},
"state": {"type": "string", "enum": ["starting", "ready", "disconnected", "terminating", "terminated", "cleanup_pending", "failed"]},
"cleanup_pending": {"type": "boolean"},
"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,
"required": ["version", "code", "message", "retryable"],
"properties": {
"version": {"type": "string", "const": "1"},
"code": {"type": "string", "minLength": 1, "maxLength": 128},
"message": {"type": "string", "minLength": 1, "maxLength": 512},
"retryable": {"type": "boolean"}
}
} }
} }
} }
+476 -1
View File
@@ -1,14 +1,57 @@
package protocol_test package protocol_test
import ( import (
"bytes"
"encoding/hex"
"encoding/json"
"reflect"
"strings" "strings"
"testing" "testing"
protocol "git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol/gen/go/protocol" protocol "git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol/gen/go/protocol"
) )
func TestDeviceRegistrationProofTranscriptIsCanonicalAndStrict(t *testing.T) {
serverID, _ := hex.DecodeString("00112233445566778899aabbccddeeff")
principalID, _ := hex.DecodeString("102132435465768798a9bacbdcedfe0f")
deviceID, _ := hex.DecodeString("ffeeddccbbaa99887766554433221100")
challenge, _ := hex.DecodeString("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
want, _ := hex.DecodeString("76657273657664692d6465766963652d70726f6f662d763100112233445566778899aabbccddeeff102132435465768798a9bacbdcedfe0fffeeddccbbaa99887766554433221100000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f0000018bcfe5687b")
got, err := protocol.DeviceRegistrationProofTranscript(serverID, principalID, deviceID, challenge, 1700000000123)
if err != nil || !bytes.Equal(got, want) {
t.Fatalf("DeviceRegistrationProofTranscript() = %x, %v; want %x", got, err, want)
}
tests := []struct {
name string
serverID, principalID, deviceID, challenge []byte
expiry int64
field, code string
}{
{"server-short", serverID[:15], principalID, deviceID, challenge, 0, "server_id", "invalid_length"},
{"server-long", append(append([]byte(nil), serverID...), 0), principalID, deviceID, challenge, 0, "server_id", "invalid_length"},
{"principal-short", serverID, principalID[:15], deviceID, challenge, 0, "principal_id", "invalid_length"},
{"principal-long", serverID, append(append([]byte(nil), principalID...), 0), deviceID, challenge, 0, "principal_id", "invalid_length"},
{"device-short", serverID, principalID, deviceID[:15], challenge, 0, "device_id", "invalid_length"},
{"device-long", serverID, principalID, append(append([]byte(nil), deviceID...), 0), challenge, 0, "device_id", "invalid_length"},
{"challenge-short", serverID, principalID, deviceID, challenge[:31], 0, "challenge", "invalid_length"},
{"challenge-long", serverID, principalID, deviceID, append(append([]byte(nil), challenge...), 0), 0, "challenge", "invalid_length"},
{"negative-expiry", serverID, principalID, deviceID, challenge, -1, "expiry_unix_milliseconds", "minimum"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := protocol.DeviceRegistrationProofTranscript(test.serverID, test.principalID, test.deviceID, test.challenge, test.expiry)
validation, ok := err.(protocol.ValidationError)
if !ok || len(validation.Violations) != 1 || validation.Violations[0] != (protocol.FieldViolation{Field: test.field, Code: test.code}) {
t.Fatalf("error = %#v; want %s/%s validation error", err, test.field, test.code)
}
})
}
}
func TestManifestRejectsForbiddenAndUnknownFields(t *testing.T) { func TestManifestRejectsForbiddenAndUnknownFields(t *testing.T) {
valid := `{"version":"1","purpose":"launch","session_id":"session-1","reconnect_sequence":0,"gateway":{"id":"gateway-1","addresses":["gateway.control.test:443"],"public_identity":"gateway-1"},"tunnel":{"versions":["verse-gateway-v1/1"],"features":["control.v1"]},"profile":{"id":"standard","bounds":{"minimum_kbps":1,"target_kbps":2,"maximum_kbps":3}},"grant":{"opaque_value":"opaque-one-time-grant-value-with-at-least-43-bytes","expires_at":"2099-01-01T00:00:00Z","audience":"versevdi-gateway"},"correlation_id":"correlation-1"}` valid := `{"version":"1","purpose":"launch","session_id":"session-1","reconnect_sequence":0,"gateway":{"id":"gateway-1","addresses":["gateway.control.test:443"],"public_identity":"gateway.control.test"},"tunnel":{"versions":["verse-gateway-v1/1"],"features":["control.v1"]},"profile":{"id":"standard","bounds":{"minimum_kbps":1,"target_kbps":2,"maximum_kbps":3}},"grant":{"opaque_value":"opaque-one-time-grant-value-with-at-least-43-bytes","expires_at":"2099-01-01T00:00:00Z","audience":"versevdi-gateway"},"correlation_id":"correlation-1"}`
manifest, err := protocol.DecodeConnectionManifest([]byte(valid)) manifest, err := protocol.DecodeConnectionManifest([]byte(valid))
if err != nil || manifest.Gateway.ID != "gateway-1" { if err != nil || manifest.Gateway.ID != "gateway-1" {
t.Fatalf("valid manifest = %+v, err = %v", manifest, err) t.Fatalf("valid manifest = %+v, err = %v", manifest, err)
@@ -21,6 +64,151 @@ func TestManifestRejectsForbiddenAndUnknownFields(t *testing.T) {
} }
} }
func TestDisplayModeIsOptionalStrictAndBounded(t *testing.T) {
displayType := reflect.TypeOf(protocol.DisplayMode{})
for _, field := range []struct {
owner reflect.Type
name string
}{
{reflect.TypeOf(protocol.SessionRequest{}), "RequestedDisplayMode"},
{reflect.TypeOf(protocol.BrokerSession{}), "RequestedDisplayMode"},
{reflect.TypeOf(protocol.BrokerSession{}), "EffectiveDisplayMode"},
{reflect.TypeOf(protocol.ManifestProfile{}), "DisplayMode"},
} {
actual, ok := field.owner.FieldByName(field.name)
if !ok || actual.Type.Kind() != reflect.Pointer || actual.Type.Elem() != displayType {
t.Fatalf("%s.%s must be *DisplayMode, got %v", field.owner, field.name, actual.Type)
}
}
request := protocol.SessionRequest{
ClientDeviceID: "device-1", DeviceKeyID: "key-1", PoolID: "pool-1", IdempotencyKey: "request-1",
}
legacy, err := protocol.EncodeSessionRequest(request)
if err != nil {
t.Fatalf("EncodeSessionRequest legacy request: %v", err)
}
if bytes.Contains(legacy, []byte("requested_display_mode")) {
t.Fatalf("legacy request disclosed an absent display mode: %s", legacy)
}
mode := protocol.DisplayMode{ResolutionWidth: 2560, ResolutionHeight: 1440, Fps: 120}
request.RequestedDisplayMode = &mode
aware, err := protocol.EncodeSessionRequest(request)
if err != nil || !bytes.Contains(aware, []byte(`"requested_display_mode":{"resolution_width":2560,"resolution_height":1440,"fps":120}`)) {
t.Fatalf("display-aware request = %s, err = %v", aware, err)
}
decoded, err := protocol.DecodeSessionRequest(aware)
if err != nil || decoded.RequestedDisplayMode == nil || *decoded.RequestedDisplayMode != mode {
t.Fatalf("DecodeSessionRequest display mode = %+v, err = %v", decoded.RequestedDisplayMode, err)
}
nullMode := bytes.Replace(aware, []byte(`{"resolution_width":2560,"resolution_height":1440,"fps":120}`), []byte("null"), 1)
if _, err := protocol.DecodeSessionRequest(nullMode); err == nil {
t.Fatalf("DecodeSessionRequest accepted explicit null display mode: %s", nullMode)
}
profile := protocol.ManifestProfile{
ID: "standard",
Bounds: protocol.ManifestBounds{MinimumKbps: 1000, TargetKbps: 2000, MaximumKbps: 3000},
}
legacyProfile, err := protocol.EncodeManifestProfile(profile)
if err != nil || bytes.Contains(legacyProfile, []byte("display_mode")) {
t.Fatalf("legacy profile = %s, err = %v", legacyProfile, err)
}
profile.DisplayMode = &mode
awareProfile, err := protocol.EncodeManifestProfile(profile)
if err != nil || !bytes.Contains(awareProfile, []byte(`"display_mode":{"resolution_width":2560,"resolution_height":1440,"fps":120}`)) {
t.Fatalf("display-aware profile = %s, err = %v", awareProfile, err)
}
for _, invalid := range []string{
`{"resolution_width":319,"resolution_height":1440,"fps":120}`,
`{"resolution_width":16385,"resolution_height":1440,"fps":120}`,
`{"resolution_width":2560,"resolution_height":199,"fps":120}`,
`{"resolution_width":2560,"resolution_height":8641,"fps":120}`,
`{"resolution_width":2560,"resolution_height":1440,"fps":0}`,
`{"resolution_width":2560,"resolution_height":1440,"fps":241}`,
`{"resolution_width":2560,"resolution_height":1440,"fps":120,"unknown":true}`,
} {
if _, err := protocol.DecodeDisplayMode([]byte(invalid)); err == nil {
t.Fatalf("DecodeDisplayMode accepted %s", invalid)
}
}
}
func TestSessionRequestRejectsClientPolicySnapshot(t *testing.T) {
if protocol.CurrentWireVersion != "2" || protocol.NMinus1WireVersion != "1" || protocol.NMinus2WireVersion != "0" {
t.Fatalf("wire compatibility = current %q, n-1 %q, n-2 %q", protocol.CurrentWireVersion, protocol.NMinus1WireVersion, protocol.NMinus2WireVersion)
}
request := `{"client_device_id":"device-1","device_key_id":"key-1","pool_id":"pool-1","idempotency_key":"request-1"}`
if _, err := protocol.DecodeSessionRequest([]byte(request)); err != nil {
t.Fatalf("policy-free SessionRequest rejected: %v", err)
}
withPolicy := strings.Replace(request, `"idempotency_key":"request-1"`, `"idempotency_key":"request-1","policy_snapshot":{}`, 1)
if _, err := protocol.DecodeSessionRequest([]byte(withPolicy)); err == nil {
t.Fatal("SessionRequest accepted client-supplied policy_snapshot")
}
if _, ok := reflect.TypeOf(protocol.SessionRequest{}).FieldByName("PolicySnapshot"); ok {
t.Fatal("SessionRequest generated a client-owned PolicySnapshot field")
}
if _, ok := reflect.TypeOf(protocol.BrokerSession{}).FieldByName("PolicySnapshot"); !ok {
t.Fatal("BrokerSession lost its server-owned PolicySnapshot disclosure")
}
}
func TestAuthenticatedSessionShapesAreRouteSpecific(t *testing.T) {
browser := `{"username":"alice","provider":"local","roles":["user"],"role":"user"}`
decodedBrowser, err := protocol.DecodeBrowserAuthenticatedSession([]byte(browser))
if err != nil || decodedBrowser.Username != "alice" {
t.Fatalf("browser session = %+v, err = %v", decodedBrowser, err)
}
native := `{"username":"alice","provider":"local","roles":["user"],"role":"user","native_identity":{"client_device_id":"device-1","device_key_id":"key-1"}}`
decodedNative, err := protocol.DecodeNativeAuthenticatedSession([]byte(native))
if err != nil || decodedNative.NativeIdentity.DeviceKeyID != "key-1" {
t.Fatalf("native session = %+v, err = %v", decodedNative, err)
}
if _, err := protocol.DecodeBrowserAuthenticatedSession([]byte(native)); err == nil {
t.Fatal("browser response accepted native identity")
}
if _, err := protocol.DecodeNativeAuthenticatedSession([]byte(browser)); err == nil {
t.Fatal("native response accepted missing native identity")
}
for _, invalid := range []string{
strings.Replace(native, `,"device_key_id":"key-1"`, "", 1),
strings.Replace(native, `"native_identity":{"client_device_id":"device-1","device_key_id":"key-1"}`, `"native_identity":null`, 1),
strings.Replace(native, `"device_key_id":"key-1"`, `"device_key_id":"key-1","unknown":true`, 1),
} {
if _, err := protocol.DecodeNativeAuthenticatedSession([]byte(invalid)); err == nil {
t.Fatalf("NativeAuthenticatedSession accepted non-atomic or unknown native identity: %s", invalid)
}
}
for _, invalidRoles := range []string{`[""]`, `["` + strings.Repeat("r", 65) + `"]`} {
invalid := strings.Replace(browser, `["user"]`, invalidRoles, 1)
if _, err := protocol.DecodeBrowserAuthenticatedSession([]byte(invalid)); err == nil {
t.Fatalf("BrowserAuthenticatedSession accepted invalid roles: %s", invalid)
}
}
}
func TestNativeTunnelCredentialIsStrictAndBounded(t *testing.T) {
valid := `{"client_device_id":"device-1","device_key_id":"key-1","certificate_chain_pem":"certificate","trust_bundle_pem":"trust","expires_at":"2099-01-01T00:00:00Z"}`
credential, err := protocol.DecodeNativeTunnelCredential([]byte(valid))
if err != nil || credential.ClientDeviceID != "device-1" {
t.Fatalf("native tunnel credential = %+v, err = %v", credential, err)
}
for _, invalid := range []string{
strings.Replace(valid, `"device_key_id":"key-1",`, "", 1),
strings.Replace(valid, `"expires_at":"2099-01-01T00:00:00Z"`, `"expires_at":"not-a-time"`, 1),
strings.Replace(valid, `"expires_at":"2099-01-01T00:00:00Z"`, `"expires_at":"2099-01-01T00:00:00+00:00"`, 1),
strings.Replace(valid, `"expires_at":"2099-01-01T00:00:00Z"`, `"expires_at":"2099-01-01T00:00:00.100Z"`, 1),
strings.Replace(valid, `"expires_at":"2099-01-01T00:00:00Z"`, `"expires_at":"2099-01-01T00:00:00Z","unknown":true`, 1),
} {
if _, err := protocol.DecodeNativeTunnelCredential([]byte(invalid)); err == nil {
t.Fatalf("NativeTunnelCredential accepted invalid payload: %s", invalid)
}
}
}
func TestPageInfoRejectsOutOfBoundsLimit(t *testing.T) { func TestPageInfoRejectsOutOfBoundsLimit(t *testing.T) {
if _, err := protocol.DecodePageInfo([]byte(`{"limit":101,"next_cursor":""}`)); err == nil { if _, err := protocol.DecodePageInfo([]byte(`{"limit":101,"next_cursor":""}`)); err == nil {
t.Fatal("DecodePageInfo accepted limit above the contract maximum") t.Fatal("DecodePageInfo accepted limit above the contract maximum")
@@ -35,3 +223,290 @@ func TestGeneratedDecodersRejectMissingRequiredFieldsAndTrailingValues(t *testin
t.Fatal("DecodeErrorEnvelope accepted a missing required boolean") t.Fatal("DecodeErrorEnvelope accepted a missing required boolean")
} }
} }
func TestGatewayContractsRejectUnknownVersionsAndFields(t *testing.T) {
registration := `{"version":"1","gateway_id":"gateway-1","instance_identity":"instance-1","certificate_identity":"cert-1","public_identity":"public-1","address":"gateway.test:443","provider_identity":"apollo-provider-1","protocol_min_version":1,"protocol_max_version":1,"connection_capacity":8,"bandwidth_capacity_kbps":100000,"features":["datagram.media"],"capabilities":{"transport":"quic","framing":"datagram-v1","media":"encoded","audio":"encoded","source_rate_control":"server","client_decode":["h264-opus"]}}`
if _, err := protocol.DecodeGatewayRegistration([]byte(registration)); err != nil {
t.Fatalf("valid gateway registration rejected: %v", err)
}
for _, invalid := range []string{
strings.Replace(registration, `"version":"1"`, `"version":"2"`, 1),
strings.Replace(registration, `"version":"1"`, `"version":"0"`, 1),
strings.Replace(registration, `"features":["datagram.media"]`, `"features":["datagram.media"],"provider_url":"https://provider.invalid"`, 1),
} {
if _, err := protocol.DecodeGatewayRegistration([]byte(invalid)); err == nil {
t.Fatalf("invalid gateway registration accepted: %s", invalid)
}
}
if _, err := protocol.DecodeGatewayRegistration([]byte("{")); err == nil {
t.Fatal("DecodeGatewayRegistration accepted malformed JSON")
}
}
func TestGatewayRegistrationRejectsInvertedProtocolBounds(t *testing.T) {
registration := `{"version":"1","gateway_id":"gateway-1","instance_identity":"instance-1","certificate_identity":"cert-1","public_identity":"public-1","address":"gateway.test:443","provider_identity":"apollo-provider-1","protocol_min_version":2,"protocol_max_version":1,"connection_capacity":8,"bandwidth_capacity_kbps":100000,"features":["datagram.media"],"capabilities":{"transport":"quic","framing":"datagram-v1","media":"encoded","audio":"encoded","source_rate_control":"server","client_decode":["h264-opus"]}}`
if _, err := protocol.DecodeGatewayRegistration([]byte(registration)); err == nil {
t.Fatal("DecodeGatewayRegistration accepted inverted protocol bounds")
}
}
func TestGatewayHeartbeatCarriesBoundedObservedTelemetry(t *testing.T) {
valid := `{"version":"1","gateway_id":"gateway-1","sequence":1,"observed_at":"2099-01-01T00:00:00Z","active_connections":1,"egress_kbps":64,"state":"ready","telemetry":{"admitted_sessions":2,"admission_rejects":3,"reconnects":4,"drain_transitions":5,"media_drops":6,"media_packets":7,"media_bytes":8000,"queue_delay_micros":9,"processing_delay_micros":10,"processing_samples":11,"pacing_delay_micros":12,"provider_errors":13,"input_rejected":14,"control_rtt_micros":15,"control_jitter_micros":16,"control_loss_ppm":17,"pending_reliable":18,"provider_state":"ready"}}`
if _, err := protocol.DecodeGatewayHeartbeat([]byte(valid)); err != nil {
t.Fatalf("valid gateway heartbeat rejected: %v", err)
}
for _, invalid := range []string{
strings.Replace(valid, `,"telemetry":{`, `,"session_id":"forbidden","telemetry":{`, 1),
strings.Replace(valid, `"control_loss_ppm":17`, `"control_loss_ppm":1000001`, 1),
strings.Replace(valid, `"provider_state":"ready"`, `"provider_state":"provider.example:47984"`, 1),
} {
if _, err := protocol.DecodeGatewayHeartbeat([]byte(invalid)); err == nil {
t.Fatalf("invalid gateway heartbeat accepted: %s", invalid)
}
}
}
func TestCapabilityIntersectionRejectsNoOverlap(t *testing.T) {
first := protocol.CapabilityProfile{Transport: "quic-tls13", Framing: "datagram-v1", Media: "encoded", Audio: "encoded", SourceRateControl: "server", ClientDecode: []string{"h264-opus"}}
if got, err := protocol.IntersectCapabilityProfiles(first, first); err != nil || !reflect.DeepEqual(got, first) {
t.Fatalf("IntersectCapabilityProfiles matching profiles = %+v, %v", got, err)
}
second := first
second.ClientDecode = []string{"hevc-opus"}
if _, err := protocol.IntersectCapabilityProfiles(first, second); err == nil {
t.Fatal("IntersectCapabilityProfiles accepted profiles without a common codec profile")
}
}
func TestCapabilityIntersectionSelectsRegisteredOrderedProfiles(t *testing.T) {
gateway := protocol.CapabilityProfile{
Transport: "quic-tls13", Framing: "datagram-v1", Media: "encoded", Audio: "encoded",
SourceRateControl: "server", ClientDecode: []string{"hevc-opus", "h264-opus"},
}
h264Client := gateway
h264Client.ClientDecode = []string{"h264-opus"}
selected, err := protocol.IntersectCapabilityProfiles(gateway, h264Client)
if err != nil || !reflect.DeepEqual(selected.ClientDecode, []string{"h264-opus"}) {
t.Fatalf("H.264 profile intersection = %+v, %v", selected, err)
}
hevcClient := gateway
hevcClient.ClientDecode = []string{"hevc-opus"}
selected, err = protocol.IntersectCapabilityProfiles(gateway, hevcClient)
if err != nil || !reflect.DeepEqual(selected.ClientDecode, []string{"hevc-opus"}) {
t.Fatalf("HEVC profile intersection = %+v, %v", selected, err)
}
noOverlap := gateway
noOverlap.ClientDecode = []string{"h264-opus"}
if _, err := protocol.IntersectCapabilityProfiles(noOverlap, hevcClient); err == nil {
t.Fatal("intersection accepted registered profiles without overlap")
}
for _, invalid := range [][]string{{"h264-hevc-opus"}, {"h264-opus", "h264-opus"}} {
profile := gateway
profile.ClientDecode = invalid
if err := profile.Validate(); err == nil {
t.Fatalf("CapabilityProfile accepted invalid registered profile set %q", invalid)
}
}
}
func TestCapabilityProfileRequiresRegisteredFraming(t *testing.T) {
valid := protocol.CapabilityProfile{
Transport: "quic-tls13", Framing: "datagram-v2", Media: "encoded",
Audio: "encoded", SourceRateControl: "server", ClientDecode: []string{"h264-opus"},
}
if err := valid.Validate(); err != nil {
t.Fatalf("registered datagram-v2 rejected: %v", err)
}
invalid := valid
invalid.Framing = "datagram-v3"
if err := invalid.Validate(); err == nil {
t.Fatal("unregistered framing accepted")
}
}
func TestTunnelAdmissionRequiresDeviceSignature(t *testing.T) {
request := protocol.TunnelAdmissionRequest{
Version: "1", SessionID: "session-1", GatewayID: "gateway-1", Audience: "versevdi-gateway",
Grant: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_", ReconnectSequence: 0,
ClientNonce: "0123456789abcdef", Capabilities: protocol.CapabilityProfile{
Transport: "quic-tls13", Framing: "datagram-v1", Media: "encoded", Audio: "encoded",
SourceRateControl: "server", ClientDecode: []string{"h264-opus"},
},
}
if _, err := protocol.EncodeTunnelAdmissionRequest(request); err == nil {
t.Fatal("EncodeTunnelAdmissionRequest accepted an unsigned device admission")
}
}
func TestTunnelAdmissionTranscriptIsDomainSeparatedAndLengthDelimited(t *testing.T) {
request := protocol.TunnelAdmissionRequest{
Version: "1", SessionID: "session", GatewayID: "gateway", Audience: "audience",
Grant: strings.Repeat("g", 43), ReconnectSequence: 0, ClientNonce: strings.Repeat("n", 16),
DeviceSignature: strings.Repeat("s", 86), Capabilities: protocol.CapabilityProfile{
Transport: "quic-tls13", Framing: "datagram-v1", Media: "encoded", Audio: "encoded",
SourceRateControl: "server", ClientDecode: []string{"h264-opus"},
},
}
want := "versevdi/tunnel-admission/v17:session7:gateway8:audience43:" + strings.Repeat("g", 43) + "1:016:" + strings.Repeat("n", 16) + "10:quic-tls1311:datagram-v17:encoded7:encoded6:server1:19:h264-opus"
if got := string(request.DeviceAdmissionTranscript()); got != want {
t.Fatalf("DeviceAdmissionTranscript() = %q, want %q", got, want)
}
}
func TestSessionAuthorityRejectsProviderRoute(t *testing.T) {
valid := `{"version":"1","session_id":"session-1","gateway_id":"gateway-1","audience":"versevdi-gateway","reconnect_sequence":0,"expires_at":"2099-01-01T00:00:00Z","capabilities":{"transport":"quic","framing":"datagram-v1","media":"encoded","audio":"encoded","source_rate_control":"server","client_decode":["h264-opus"]},"provider_profile":"apollo","provider_identity":"provider-1"}`
if _, err := protocol.DecodeSessionAuthority([]byte(valid)); err != nil {
t.Fatalf("valid session authority rejected: %v", err)
}
if _, err := protocol.DecodeSessionAuthority([]byte(strings.Replace(valid, `"provider_identity":"provider-1"`, `"provider_identity":"provider-1","rtsp_url":"rtsp://provider.invalid"`, 1))); err == nil {
t.Fatal("session authority accepted a provider route")
}
}
func TestClientSessionAuthorityIsStrictAndProviderFree(t *testing.T) {
authority := protocol.ClientSessionAuthority{
Version: "1", SessionID: "session-1", GatewayID: "gateway-1", Audience: "versevdi-gateway",
ReconnectSequence: 2, ExpiresAt: "2099-01-01T00:00:00Z", Capabilities: protocol.CapabilityProfile{
Transport: "quic-tls13", Framing: "datagram-v1", Media: "encoded", Audio: "encoded",
SourceRateControl: "server", ClientDecode: []string{"h264-opus"},
},
}
encoded, err := protocol.EncodeClientSessionAuthority(authority)
if err != nil {
t.Fatalf("EncodeClientSessionAuthority() error = %v", err)
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(encoded, &fields); err != nil {
t.Fatalf("encoded client authority is not JSON: %v", err)
}
wantFields := map[string]bool{
"version": true, "session_id": true, "gateway_id": true, "audience": true,
"reconnect_sequence": true, "expires_at": true, "capabilities": true,
}
if len(fields) != len(wantFields) {
t.Fatalf("encoded client authority fields = %v; want exactly %v", fields, wantFields)
}
for field := range fields {
if !wantFields[field] {
t.Fatalf("encoded client authority contains forbidden field %q", field)
}
}
if bytes.Contains(encoded, []byte("provider_")) {
t.Fatalf("encoded client authority disclosed provider data: %s", encoded)
}
decoded, err := protocol.DecodeClientSessionAuthority(encoded)
if err != nil || !reflect.DeepEqual(decoded, authority) {
t.Fatalf("DecodeClientSessionAuthority() = %+v, %v; want %+v", decoded, err, authority)
}
for _, field := range []string{"version", "session_id", "gateway_id", "audience", "reconnect_sequence", "expires_at", "capabilities"} {
missing := make(map[string]json.RawMessage, len(fields)-1)
for key, value := range fields {
if key != field {
missing[key] = value
}
}
payload, err := json.Marshal(missing)
if err != nil {
t.Fatal(err)
}
if _, err := protocol.DecodeClientSessionAuthority(payload); err == nil {
t.Fatalf("DecodeClientSessionAuthority accepted missing %q", field)
}
}
for name, value := range map[string]string{
"provider_profile": `"apollo"`,
"provider_identity": `"provider-1"`,
"provider_url": `"https://provider.invalid"`,
"management_host": `"provider.invalid"`,
"unknown": `true`,
} {
payload := append(append([]byte(nil), encoded[:len(encoded)-1]...), []byte(`,"`+name+`":`+value+`}`)...)
if _, err := protocol.DecodeClientSessionAuthority(payload); err == nil {
t.Fatalf("DecodeClientSessionAuthority accepted injected %q", name)
}
}
for _, expiresAt := range []string{"not-a-time", "2099-01-01T00:00:00+00:00", "2099-01-01T00:00:00.100Z"} {
payload := bytes.Replace(encoded, []byte("2099-01-01T00:00:00Z"), []byte(expiresAt), 1)
if _, err := protocol.DecodeClientSessionAuthority(payload); err == nil {
t.Fatalf("DecodeClientSessionAuthority accepted expires_at %q", expiresAt)
}
}
if _, err := protocol.DecodeClientSessionAuthority(append(encoded, []byte(" {}")...)); err == nil {
t.Fatal("DecodeClientSessionAuthority accepted trailing JSON")
}
}
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","stream_policy":{"resolution_width":2560,"resolution_height":1440,"fps":120,"codec":"HEVC","bitrate_kbps":40000,"audio_enabled":true},"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")
}
for _, invalid := range []string{
strings.Replace(valid, `,"stream_policy":{"resolution_width":2560,"resolution_height":1440,"fps":120,"codec":"HEVC","bitrate_kbps":40000,"audio_enabled":true}`, "", 1),
strings.Replace(valid, `"fps":120`, `"fps":241`, 1),
strings.Replace(valid, `"codec":"HEVC"`, `"codec":"VP9"`, 1),
strings.Replace(valid, `"audio_enabled":true`, `"audio_enabled":true,"unknown":false`, 1),
} {
if _, err := protocol.DecodeProviderSessionWork([]byte(invalid)); err == nil {
t.Fatalf("provider work accepted invalid stream policy: %s", invalid)
}
}
}
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")
}
}
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env python3
"""Require immutable commits for third-party Gitea workflow actions."""
from __future__ import annotations
import pathlib
import re
ROOT = pathlib.Path(__file__).resolve().parents[1]
USE = re.compile(r"^\s*-\s+uses:\s+([^@\s]+)@([^\s#]+)", re.MULTILINE)
for workflow in sorted((ROOT / ".gitea/workflows").glob("*.y*ml")):
for action, revision in USE.findall(workflow.read_text(encoding="utf-8")):
if not action.startswith("./") and not re.fullmatch(r"[0-9a-f]{40}", revision):
raise SystemExit(f"{workflow.relative_to(ROOT)}: mutable action {action}@{revision}")
print("Protocol CI action references are immutable")
+29 -15
View File
@@ -30,6 +30,9 @@ SECRET_PATTERNS = (
re.compile(rb"\bgh[pousr]_[A-Za-z0-9]{20,}\b"), re.compile(rb"\bgh[pousr]_[A-Za-z0-9]{20,}\b"),
re.compile(rb"\bsk-[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: def fail(message: str) -> None:
@@ -53,20 +56,38 @@ def check_generated_provenance() -> None:
def check_manifest_schema() -> None: def check_manifest_schema() -> None:
schema = json.loads((ROOT / "schemas/control-v1.schema.json").read_text(encoding="utf-8")) schema = json.loads((ROOT / "schemas/control-v1.schema.json").read_text(encoding="utf-8"))
definitions = schema.get("$defs", {}) definitions = schema.get("$defs", {})
for name in ("ConnectionManifest", "ManifestGateway", "ManifestTunnel", "ManifestProfile", "ManifestBounds", "GrantReference"): for name, definition in definitions.items():
properties = definitions.get(name, {}).get("properties", {}) for field in definition.get("properties", {}):
forbidden = sorted(FORBIDDEN_WIRE_FIELDS.intersection(properties)) if any(forbidden in field.lower() for forbidden in FORBIDDEN_WIRE_FIELDS):
if forbidden: if (name, field) not in ALLOWED_SECRET_PROPERTIES:
fail(f"{name} exposes forbidden wire fields: {forbidden}") 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: def check_text_boundaries() -> None:
paths = [ paths = [
ROOT / "openapi/control-v1.yaml", ROOT / "openapi/control-v1.yaml",
ROOT / "schemas/control-v1.schema.json",
ROOT / "proto/versevdi/control/v1/control.proto", ROOT / "proto/versevdi/control/v1/control.proto",
ROOT / "proto/versevdi/tunnel/v1/tunnel.proto",
ROOT / "frames/datagram-v1.md", ROOT / "frames/datagram-v1.md",
ROOT / "frames/datagram-v2.md",
ROOT / "frames/registry.json", ROOT / "frames/registry.json",
ROOT / "registries/features.json", ROOT / "registries/features.json",
ROOT / "registries/datagrams.json", ROOT / "registries/datagrams.json",
@@ -77,14 +98,7 @@ def check_text_boundaries() -> None:
if field in text: if field in text:
fail(f"{path.relative_to(ROOT)} contains forbidden wire field {field}") fail(f"{path.relative_to(ROOT)} contains forbidden wire field {field}")
generated_paths = list((ROOT / "gen").rglob("*")) check_proto_boundaries(ROOT / "proto/versevdi/tunnel/v1/tunnel.proto")
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}")
def check_secret_canaries() -> None: def check_secret_canaries() -> None:
+426 -9
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\"}}) }}") 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: if "maxLength" in prop:
lines.append(f"\tif len(v.{field}) > {prop['maxLength']} {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"max_length\"}}) }}") 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: if "const" in prop:
lines.append(f"\tif v.{field} != \"{prop['const']}\" && v.{field} != \"\" {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"invalid_value\"}}) }}") lines.append(f"\tif v.{field} != \"{prop['const']}\" && v.{field} != \"\" {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"invalid_value\"}}) }}")
if "enum" in prop: 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"}) } }' '\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) % (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 prop.get("type") == "integer":
if "minimum" in prop: if "minimum" in prop:
lines.append(f"\tif v.{field} != 0 && v.{field} < {prop['minimum']} {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"minimum\"}}) }}") lines.append(f"\tif v.{field} != 0 && v.{field} < {prop['minimum']} {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"minimum\"}}) }}")
@@ -145,12 +149,34 @@ def go_validation(definition: dict[str, Any]) -> list[str]:
lines.append(f"\tif len(v.{field}) < {prop['minItems']} {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"min_items\"}}) }}") lines.append(f"\tif len(v.{field}) < {prop['minItems']} {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"min_items\"}}) }}")
if "maxItems" in prop: if "maxItems" in prop:
lines.append(f"\tif len(v.{field}) > {prop['maxItems']} {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"max_items\"}}) }}") lines.append(f"\tif len(v.{field}) > {prop['maxItems']} {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"max_items\"}}) }}")
item_ref = ref_name(prop.get("items", {})) items = prop.get("items", {})
if items.get("type") == "string" and "minLength" in items:
lines.append(f"\tfor _, item := range v.{field} {{ if len(item) < {items['minLength']} {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"min_item_length\"}}) }} }}")
if items.get("type") == "string" and "maxLength" in items:
lines.append(f"\tfor _, item := range v.{field} {{ if len(item) > {items['maxLength']} {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"max_item_length\"}}) }} }}")
if items.get("type") == "string" and "x-max-bytes" in items:
lines.append(f"\tfor _, item := range v.{field} {{ if len(item) > {items['x-max-bytes']} {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"max_item_bytes\"}}) }} }}")
if "enum" in items:
allowed = " || ".join(f'item == "{value}"' for value in items["enum"])
lines.append(f"\tfor _, item := range v.{field} {{ if !({allowed}) {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"invalid_item\"}}) }} }}")
if prop.get("uniqueItems") and items.get("type") == "string":
lines.append(f"\tfor index, item := range v.{field} {{ for prior := 0; prior < index; prior++ {{ if item == v.{field}[prior] {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"duplicate_item\"}}) }} }} }}")
item_ref = ref_name(items)
if item_ref: if item_ref:
lines.append(f"\tfor index := range v.{field} {{ if err := v.{field}[index].Validate(); err != nil {{ violations = append(violations, FieldViolation{{Field: fmt.Sprintf(\"{prop_name}[%d]\", index), Code: \"invalid_item\"}}) }} }}") lines.append(f"\tfor index := range v.{field} {{ if err := v.{field}[index].Validate(); err != nil {{ violations = append(violations, FieldViolation{{Field: fmt.Sprintf(\"{prop_name}[%d]\", index), Code: \"invalid_item\"}}) }} }}")
reference = ref_name(prop) reference = ref_name(prop)
if reference: if reference:
lines.append(f"\tif err := v.{field}.Validate(); err != nil {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"invalid_object\"}}) }}") validation = f"if err := v.{field}.Validate(); err != nil {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"invalid_object\"}}) }}"
if prop_name not in required:
lines.append(f"\tif v.{field} != nil {{ {validation} }}")
else:
lines.append(f"\t{validation}")
if name in {"AllocationPolicy", "ManifestBounds"}:
lines.append("\tif v.MinimumKbps > v.TargetKbps || v.TargetKbps > v.MaximumKbps { violations = append(violations, FieldViolation{Field: \"bounds\", Code: \"invalid_order\"}) }")
if name == "GatewayRegistration":
lines.append("\tif v.ProtocolMinVersion > v.ProtocolMaxVersion { violations = append(violations, FieldViolation{Field: \"protocol_version\", Code: \"invalid_order\"}) }")
if name == "ChannelFrame":
lines.append("\tif v.FragmentIndex >= v.FragmentCount { violations = append(violations, FieldViolation{Field: \"fragment_index\", Code: \"invalid_order\"}) }")
return lines return lines
@@ -161,10 +187,13 @@ def generate_go(defs: dict[str, dict[str, Any]], schema_hash: str, version: str,
"", "",
"import (", "import (",
"\"bytes\"", "\"bytes\"",
"\"encoding/binary\"",
"\"encoding/base64\"",
"\"encoding/json\"", "\"encoding/json\"",
"\"errors\"", "\"errors\"",
"\"fmt\"", "\"fmt\"",
"\"reflect\"", "\"reflect\"",
"\"strings\"",
"\"time\"", "\"time\"",
")", ")",
"", "",
@@ -194,7 +223,10 @@ def generate_go(defs: dict[str, dict[str, Any]], schema_hash: str, version: str,
required = set(definition.get("required", [])) required = set(definition.get("required", []))
for prop_name, prop in definition.get("properties", {}).items(): for prop_name, prop in definition.get("properties", {}).items():
tag = prop_name + (",omitempty" if prop_name not in required else "") tag = prop_name + (",omitempty" if prop_name not in required else "")
out.append(f"\t{go_field(prop_name)} {prop_type(prop, 'go')} `json:\"{tag}\"`") typ = prop_type(prop, "go")
if prop_name not in required and ref_name(prop):
typ = "*" + typ
out.append(f"\t{go_field(prop_name)} {typ} `json:\"{tag}\"`")
out.extend(["}", ""]) out.extend(["}", ""])
for name in sorted(defs): for name in sorted(defs):
out.append(f"func (v {name}) Validate() error {{") out.append(f"func (v {name}) Validate() error {{")
@@ -216,7 +248,12 @@ def generate_go(defs: dict[str, dict[str, Any]], schema_hash: str, version: str,
% (prop_name, prop_name) % (prop_name, prop_name)
) )
for prop_name, prop in defs[name].get("properties", {}).items(): for prop_name, prop in defs[name].get("properties", {}).items():
if "x-max-bytes" in prop: if prop_name not in required_fields and ref_name(prop):
out.append(
'\tif raw, ok := fields["%s"]; ok && bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { return value, ValidationError{Violations: []FieldViolation{{Field: "%s", Code: "invalid_object"}}} }'
% (prop_name, prop_name)
)
if "x-max-bytes" in prop and prop.get("type") != "string":
out.append( out.append(
'\tif raw, ok := fields["%s"]; ok && len(raw) > %d { return value, ValidationError{Violations: []FieldViolation{{Field: "%s", Code: "max_bytes"}}} }' '\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) % (prop_name, prop["x-max-bytes"], prop_name)
@@ -235,6 +272,53 @@ def generate_go(defs: dict[str, dict[str, Any]], schema_hash: str, version: str,
out.append("\treturn json.Marshal(value)") out.append("\treturn json.Marshal(value)")
out.append("}") out.append("}")
out.append("") out.append("")
out.extend([
"func DeviceRegistrationProofTranscript(serverID, principalID, deviceID, challenge []byte, expiryUnixMilliseconds int64) ([]byte, error) {",
"\tfor _, value := range []struct { field string; bytes []byte; length int }{{\"server_id\", serverID, 16}, {\"principal_id\", principalID, 16}, {\"device_id\", deviceID, 16}, {\"challenge\", challenge, 32}} {",
"\t\tif len(value.bytes) != value.length { return nil, ValidationError{Violations: []FieldViolation{{Field: value.field, Code: \"invalid_length\"}}} }",
"\t}",
"\tif expiryUnixMilliseconds < 0 { return nil, ValidationError{Violations: []FieldViolation{{Field: \"expiry_unix_milliseconds\", Code: \"minimum\"}}} }",
"\ttranscript := make([]byte, 0, 112)",
"\ttranscript = append(transcript, \"versevdi-device-proof-v1\"...)",
"\ttranscript = append(transcript, serverID...)",
"\ttranscript = append(transcript, principalID...)",
"\ttranscript = append(transcript, deviceID...)",
"\ttranscript = append(transcript, challenge...)",
"\tvar expiry [8]byte",
"\tbinary.BigEndian.PutUint64(expiry[:], uint64(expiryUnixMilliseconds))",
"\treturn append(transcript, expiry[:]...), nil",
"}",
"",
"var ErrNoCapabilityOverlap = errors.New(\"no capability overlap\")",
"",
"func IntersectCapabilityProfiles(profiles ...CapabilityProfile) (CapabilityProfile, error) {",
"\tif len(profiles) == 0 { return CapabilityProfile{}, ErrNoCapabilityOverlap }",
"\tselected := profiles[0]",
"\tif err := selected.Validate(); err != nil { return CapabilityProfile{}, ErrNoCapabilityOverlap }",
"\tcommon := append([]string(nil), selected.ClientDecode...)",
"\tfor _, profile := range profiles[1:] {",
"\t\tif err := profile.Validate(); err != nil || profile.Transport != selected.Transport || profile.Framing != selected.Framing || profile.Media != selected.Media || profile.Audio != selected.Audio || profile.SourceRateControl != selected.SourceRateControl { return CapabilityProfile{}, ErrNoCapabilityOverlap }",
"\t\tnext := common[:0]",
"\t\tfor _, candidate := range common { for _, offered := range profile.ClientDecode { if candidate == offered { next = append(next, candidate); break } } }",
"\t\tcommon = next",
"\t\tif len(common) == 0 { return CapabilityProfile{}, ErrNoCapabilityOverlap }",
"\t}",
"\tselected.ClientDecode = common",
"\treturn selected, nil",
"}",
"",
])
out.extend([
"func (v TunnelAdmissionRequest) DeviceAdmissionTranscript() []byte {",
"\tfields := []string{v.SessionID, v.GatewayID, v.Audience, v.Grant, fmt.Sprintf(\"%d\", v.ReconnectSequence), v.ClientNonce, v.Capabilities.Transport, v.Capabilities.Framing, v.Capabilities.Media, v.Capabilities.Audio, v.Capabilities.SourceRateControl, fmt.Sprintf(\"%d\", len(v.Capabilities.ClientDecode))}",
"\tfields = append(fields, v.Capabilities.ClientDecode...)",
"\tvar transcript strings.Builder",
"\ttranscript.WriteString(\"versevdi/tunnel-admission/v1\")",
"\tfor _, field := range fields { fmt.Fprintf(&transcript, \"%d:%s\", len(field), field) }",
"\treturn []byte(transcript.String())",
"}",
"",
])
# Use io.EOF in generated code without making every generated decoder depend on # Use io.EOF in generated code without making every generated decoder depend on
# error-string comparison; replace the deliberately compact placeholder. # error-string comparison; replace the deliberately compact placeholder.
text = "\n".join(out).replace('"errors"\n"fmt"', '"errors"\n"fmt"\n\"io"') text = "\n".join(out).replace('"errors"\n"fmt"', '"errors"\n"fmt"\n\"io"')
@@ -272,6 +356,76 @@ def swift_type(prop: dict[str, Any]) -> str:
return "String" return "String"
def rust_validation(definition: dict[str, Any]) -> list[str]:
lines: list[str] = []
required = set(definition.get("required", []))
for prop_name, prop in definition.get("properties", {}).items():
field = rust_field(prop_name)
value = f"self.{field}"
if prop_name not in required:
value = f"value"
lines.append(f" if let Some(value) = &self.{field} {{")
prefix, suffix = " ", " }"
else:
prefix, suffix = "", ""
if prop.get("type") == "string":
if prop_name in required and prop.get("minLength", 0) > 0:
lines.append(f" {prefix}if {value}.is_empty() {{ return Err(ValidationError::new(\"{prop_name}\", \"required\")); }}")
if "minLength" in prop:
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") == "date-time":
lines.append(f" {prefix}if !valid_rfc3339_utc({value}.as_str()) {{ return Err(ValidationError::new(\"{prop_name}\", \"invalid_time\")); }}")
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\")); }}")
if "maximum" in prop:
lines.append(f" {prefix}if {value} > {prop['maximum']} {{ return Err(ValidationError::new(\"{prop_name}\", \"maximum\")); }}")
if prop.get("type") == "array":
if "minItems" in prop:
lines.append(f" {prefix}if {value}.len() < {prop['minItems']} {{ return Err(ValidationError::new(\"{prop_name}\", \"min_items\")); }}")
if "maxItems" in prop:
lines.append(f" {prefix}if {value}.len() > {prop['maxItems']} {{ return Err(ValidationError::new(\"{prop_name}\", \"max_items\")); }}")
items = prop.get("items", {})
if items.get("type") == "string" and "minLength" in items:
lines.append(f" {prefix}for item in {value}.iter() {{ if item.as_bytes().len() < {items['minLength']} {{ return Err(ValidationError::new(\"{prop_name}\", \"min_item_length\")); }} }}")
if items.get("type") == "string" and "maxLength" in items:
lines.append(f" {prefix}for item in {value}.iter() {{ if item.as_bytes().len() > {items['maxLength']} {{ return Err(ValidationError::new(\"{prop_name}\", \"max_item_length\")); }} }}")
if items.get("type") == "string" and "x-max-bytes" in items:
lines.append(f" {prefix}for item in {value}.iter() {{ if item.as_bytes().len() > {items['x-max-bytes']} {{ return Err(ValidationError::new(\"{prop_name}\", \"max_item_bytes\")); }} }}")
if "enum" in items:
allowed = " && ".join(f'item != \"{item}\"' for item in items["enum"])
lines.append(f" {prefix}for item in {value}.iter() {{ if {allowed} {{ return Err(ValidationError::new(\"{prop_name}\", \"invalid_item\")); }} }}")
if prop.get("uniqueItems") and items.get("type") == "string":
lines.append(f" {prefix}for (index, item) in {value}.iter().enumerate() {{ if {value}[..index].contains(item) {{ return Err(ValidationError::new(\"{prop_name}\", \"duplicate_item\")); }} }}")
item_ref = ref_name(items)
if item_ref:
lines.append(f" {prefix}for item in {value}.iter() {{ item.validate().map_err(|_| ValidationError::new(\"{prop_name}\", \"invalid_item\"))?; }}")
reference = ref_name(prop)
if reference:
lines.append(f" {prefix}{value}.validate().map_err(|_| ValidationError::new(\"{prop_name}\", \"invalid_object\"))?;")
if suffix:
lines.append(suffix)
name = definition["name"]
if name in {"AllocationPolicy", "ManifestBounds"}:
lines.append(" if self.minimumKbps > self.targetKbps || self.targetKbps > self.maximumKbps { return Err(ValidationError::new(\"bounds\", \"invalid_order\")); }")
if name == "GatewayRegistration":
lines.append(" if self.protocolMinVersion > self.protocolMaxVersion { return Err(ValidationError::new(\"protocol_version\", \"invalid_order\")); }")
if name == "ChannelFrame":
lines.append(" if self.fragmentIndex >= self.fragmentCount { return Err(ValidationError::new(\"fragment_index\", \"invalid_order\")); }")
return lines
def generate_rust(defs: dict[str, dict[str, Any]], schema_hash: str, compatibility: dict[str, Any]) -> str: def generate_rust(defs: dict[str, dict[str, Any]], schema_hash: str, compatibility: dict[str, Any]) -> str:
out = [ out = [
"// Code generated by tools/generate.py; DO NOT EDIT.", "// Code generated by tools/generate.py; DO NOT EDIT.",
@@ -282,6 +436,44 @@ def generate_rust(defs: dict[str, dict[str, Any]], schema_hash: str, compatibili
f'pub const N_MINUS_2_WIRE_VERSION: &str = "{compatibility["n_minus_2"]}";', f'pub const N_MINUS_2_WIRE_VERSION: &str = "{compatibility["n_minus_2"]}";',
"pub type JsonObject = std::collections::BTreeMap<String, String>;", "pub type JsonObject = std::collections::BTreeMap<String, String>;",
"", "",
"#[derive(Debug, Clone, PartialEq, Eq)]",
"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,",
" }",
"}",
"fn valid_rfc3339_utc(value: &str) -> bool {",
" let bytes = value.as_bytes();",
" if bytes.len() < 20 || bytes.len() > 30 || bytes[4] != b'-' || bytes[7] != b'-' || bytes[10] != b'T' || bytes[13] != b':' || bytes[16] != b':' || *bytes.last().unwrap() != b'Z' { return false; }",
" let digits = |start: usize, end: usize| -> Option<u32> { bytes.get(start..end)?.iter().try_fold(0u32, |value, byte| if byte.is_ascii_digit() { Some(value * 10 + u32::from(*byte - b'0')) } else { None }) };",
" let (year, month, day, hour, minute, second) = match (digits(0, 4), digits(5, 7), digits(8, 10), digits(11, 13), digits(14, 16), digits(17, 19)) { (Some(year), Some(month), Some(day), Some(hour), Some(minute), Some(second)) => (year, month, day, hour, minute, second), _ => return false };",
" if hour > 23 || minute > 59 || second > 59 { return false; }",
" let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);",
" let days = match month { 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, 4 | 6 | 9 | 11 => 30, 2 if leap => 29, 2 => 28, _ => return false };",
" if day == 0 || day > days { return false; }",
" if bytes.len() == 20 { return true; }",
" let fraction = &bytes[20..bytes.len() - 1];",
" bytes[19] == b'.' && !fraction.is_empty() && fraction.len() <= 9 && fraction.iter().all(u8::is_ascii_digit) && *fraction.last().unwrap() != b'0'",
"}",
"",
] ]
for name in sorted(defs): for name in sorted(defs):
definition = defs[name] definition = defs[name]
@@ -292,11 +484,148 @@ def generate_rust(defs: dict[str, dict[str, Any]], schema_hash: str, compatibili
typ = rust_type(prop) typ = rust_type(prop)
if prop_name not in required: if prop_name not in required:
typ = f"Option<{typ}>" typ = f"Option<{typ}>"
out.append(f" pub {field}: {typ},") out.append(f" {field}: {typ},")
out.extend(["}", ""]) out.extend(["}", ""])
parameters: list[str] = []
assignments: list[str] = []
for prop_name, prop in definition.get("properties", {}).items():
field = rust_field(prop_name)
typ = rust_type(prop)
if prop_name not in required:
typ = f"Option<{typ}>"
parameters.append(f"{field}: {typ}")
assignments.append(field)
out.append(f"impl {name} {{")
out.append(f" pub fn new({', '.join(parameters)}) -> Result<Self, ValidationError> {{")
out.append(f" let value = Self {{ {', '.join(assignments)} }};")
out.append(" value.validate()?;")
out.append(" Ok(value)")
out.append(" }")
out.append(" pub fn validate(&self) -> Result<(), ValidationError> {")
out.extend(rust_validation(definition))
out.append(" Ok(())")
out.append(" }")
for prop_name, prop in definition.get("properties", {}).items():
field = rust_field(prop_name)
typ = rust_type(prop)
if prop_name not in required:
typ = f"Option<{typ}>"
out.append(f" pub fn {field}(&self) -> &{typ} {{ &self.{field} }}")
if name == "TunnelAdmissionRequest":
out.extend([
" pub fn device_admission_transcript(&self) -> Vec<u8> {",
" let reconnect_sequence = self.reconnectSequence.to_string();",
" let client_decode_count = self.capabilities.clientDecode.len().to_string();",
" let mut fields = vec![self.sessionId.as_str(), self.gatewayId.as_str(), self.audience.as_str(), self.grant.as_str(), reconnect_sequence.as_str(), self.clientNonce.as_str(), self.capabilities.transport.as_str(), self.capabilities.framing.as_str(), self.capabilities.media.as_str(), self.capabilities.audio.as_str(), self.capabilities.sourceRateControl.as_str(), client_decode_count.as_str()];",
" fields.extend(self.capabilities.clientDecode.iter().map(String::as_str));",
" let mut transcript = String::from(\"versevdi/tunnel-admission/v1\");",
" for field in fields { transcript.push_str(&format!(\"{}:{}\", field.as_bytes().len(), field)); }",
" transcript.into_bytes()",
" }",
])
out.extend(["}", ""])
out.extend([
"pub fn device_registration_proof_transcript(server_id: &[u8], principal_id: &[u8], device_id: &[u8], challenge: &[u8], expiry_unix_milliseconds: i64) -> Result<Vec<u8>, ValidationError> {",
" for (field, value, length) in [(\"server_id\", server_id, 16), (\"principal_id\", principal_id, 16), (\"device_id\", device_id, 16), (\"challenge\", challenge, 32)] {",
" if value.len() != length { return Err(ValidationError::new(field, \"invalid_length\")); }",
" }",
" if expiry_unix_milliseconds < 0 { return Err(ValidationError::new(\"expiry_unix_milliseconds\", \"minimum\")); }",
" let mut transcript = Vec::with_capacity(112);",
" transcript.extend_from_slice(b\"versevdi-device-proof-v1\");",
" transcript.extend_from_slice(server_id);",
" transcript.extend_from_slice(principal_id);",
" transcript.extend_from_slice(device_id);",
" transcript.extend_from_slice(challenge);",
" transcript.extend_from_slice(&(expiry_unix_milliseconds as u64).to_be_bytes());",
" Ok(transcript)",
"}",
"",
"pub fn intersect_capability_profiles(profiles: &[CapabilityProfile]) -> Result<CapabilityProfile, ValidationError> {",
" let mut selected = profiles.first().ok_or_else(|| ValidationError::new(\"capabilities\", \"no_overlap\"))?.clone();",
" selected.validate().map_err(|_| ValidationError::new(\"capabilities\", \"no_overlap\"))?;",
" for profile in &profiles[1..] {",
" profile.validate().map_err(|_| ValidationError::new(\"capabilities\", \"no_overlap\"))?;",
" if profile.transport != selected.transport || profile.framing != selected.framing || profile.media != selected.media || profile.audio != selected.audio || profile.sourceRateControl != selected.sourceRateControl { return Err(ValidationError::new(\"capabilities\", \"no_overlap\")); }",
" selected.clientDecode.retain(|candidate| profile.clientDecode.contains(candidate));",
" if selected.clientDecode.is_empty() { return Err(ValidationError::new(\"capabilities\", \"no_overlap\")); }",
" }",
" Ok(selected)",
"}",
"",
])
return "\n".join(out) return "\n".join(out)
def swift_validation(definition: dict[str, Any]) -> list[str]:
lines: list[str] = []
required = set(definition.get("required", []))
for prop_name, prop in definition.get("properties", {}).items():
field = swift_field(prop_name)
value = f"self.{field}"
if prop_name not in required:
value = "value"
lines.append(f" if let value = self.{field} {{")
prefix, suffix = " ", " }"
else:
prefix, suffix = "", ""
if prop.get("type") == "string":
if prop_name in required and prop.get("minLength", 0) > 0:
lines.append(f" {prefix}if {value}.isEmpty {{ throw ContractValidationError(field: \"{prop_name}\", code: \"required\") }}")
if "minLength" in prop:
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:
allowed = ", ".join(f'\"{item}\"' for item in prop["enum"])
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 !validRFC3339UTC({value}) {{ 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\") }}")
if "maximum" in prop:
lines.append(f" {prefix}if {value} > {prop['maximum']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"maximum\") }}")
if prop.get("type") == "array":
if "minItems" in prop:
lines.append(f" {prefix}if {value}.count < {prop['minItems']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"min_items\") }}")
if "maxItems" in prop:
lines.append(f" {prefix}if {value}.count > {prop['maxItems']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"max_items\") }}")
items = prop.get("items", {})
if items.get("type") == "string" and "minLength" in items:
lines.append(f" {prefix}for item in {value} where item.utf8.count < {items['minLength']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"min_item_length\") }}")
if items.get("type") == "string" and "maxLength" in items:
lines.append(f" {prefix}for item in {value} where item.utf8.count > {items['maxLength']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"max_item_length\") }}")
if items.get("type") == "string" and "x-max-bytes" in items:
lines.append(f" {prefix}for item in {value} where item.utf8.count > {items['x-max-bytes']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"max_item_bytes\") }}")
if "enum" in items:
allowed = ", ".join(f'\"{item}\"' for item in items["enum"])
lines.append(f" {prefix}for item in {value} where ![{allowed}].contains(item) {{ throw ContractValidationError(field: \"{prop_name}\", code: \"invalid_item\") }}")
if prop.get("uniqueItems") and items.get("type") == "string":
lines.append(f" {prefix}if Set({value}).count != {value}.count {{ throw ContractValidationError(field: \"{prop_name}\", code: \"duplicate_item\") }}")
item_ref = ref_name(items)
if item_ref:
lines.append(f" {prefix}for item in {value} {{ try item.validate() }}")
reference = ref_name(prop)
if reference:
lines.append(f" {prefix}try {value}.validate()")
if suffix:
lines.append(suffix)
name = definition["name"]
if name in {"AllocationPolicy", "ManifestBounds"}:
lines.append(" if minimumKbps > targetKbps || targetKbps > maximumKbps { throw ContractValidationError(field: \"bounds\", code: \"invalid_order\") }")
if name == "GatewayRegistration":
lines.append(" if protocolMinVersion > protocolMaxVersion { throw ContractValidationError(field: \"protocol_version\", code: \"invalid_order\") }")
if name == "ChannelFrame":
lines.append(" if fragmentIndex >= fragmentCount { throw ContractValidationError(field: \"fragment_index\", code: \"invalid_order\") }")
return lines
def generate_swift(defs: dict[str, dict[str, Any]], schema_hash: str, compatibility: dict[str, Any]) -> str: def generate_swift(defs: dict[str, dict[str, Any]], schema_hash: str, compatibility: dict[str, Any]) -> str:
out = [ out = [
"// Code generated by tools/generate.py; DO NOT EDIT.", "// Code generated by tools/generate.py; DO NOT EDIT.",
@@ -306,6 +635,34 @@ def generate_swift(defs: dict[str, dict[str, Any]], schema_hash: str, compatibil
f'public let currentWireVersion = "{compatibility["current"]}"', f'public let currentWireVersion = "{compatibility["current"]}"',
f'public let nMinus1WireVersion = "{compatibility["n_minus_1"]}"', f'public let nMinus1WireVersion = "{compatibility["n_minus_1"]}"',
f'public let nMinus2WireVersion = "{compatibility["n_minus_2"]}"', 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",
"}",
"private func validRFC3339UTC(_ value: String) -> Bool {",
" let bytes = Array(value.utf8)",
" guard (20...30).contains(bytes.count), bytes[4] == 45, bytes[7] == 45, bytes[10] == 84, bytes[13] == 58, bytes[16] == 58, bytes.last == 90 else { return false }",
" func digits(_ range: Range<Int>) -> Int? {",
" var result = 0",
" for index in range { guard bytes[index] >= 48 && bytes[index] <= 57 else { return nil }; result = result * 10 + Int(bytes[index] - 48) }",
" return result",
" }",
" guard let year = digits(0..<4), let month = digits(5..<7), let day = digits(8..<10), let hour = digits(11..<13), let minute = digits(14..<16), let second = digits(17..<19), hour <= 23, minute <= 59, second <= 59 else { return false }",
" let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)",
" let days: Int",
" switch month { case 1, 3, 5, 7, 8, 10, 12: days = 31; case 4, 6, 9, 11: days = 30; case 2: days = leap ? 29 : 28; default: return false }",
" guard day > 0 && day <= days else { return false }",
" if bytes.count == 20 { return true }",
" let fraction = bytes[20..<(bytes.count - 1)]",
" return bytes[19] == 46 && !fraction.isEmpty && fraction.count <= 9 && fraction.allSatisfy { $0 >= 48 && $0 <= 57 } && fraction.last != 48",
"}",
"", "",
] ]
for name in sorted(defs): for name in sorted(defs):
@@ -320,16 +677,76 @@ def generate_swift(defs: dict[str, dict[str, Any]], schema_hash: str, compatibil
out.append(" enum CodingKeys: String, CodingKey {") out.append(" enum CodingKeys: String, CodingKey {")
for prop_name in definition.get("properties", {}): for prop_name in definition.get("properties", {}):
out.append(f" case {swift_field(prop_name)} = \"{prop_name}\"") out.append(f" case {swift_field(prop_name)} = \"{prop_name}\"")
out.extend([" }", "", " public init(from decoder: Decoder) throws {",]) parameters: list[str] = []
for prop_name, prop in definition.get("properties", {}).items():
typ = swift_type(prop)
if prop_name not in required:
typ += "?"
parameters.append(f"{swift_field(prop_name)}: {typ}")
out.extend([" }", "", f" public init({', '.join(parameters)}) throws {{"])
for prop_name in definition.get("properties", {}):
field = swift_field(prop_name)
out.append(f" self.{field} = {field}")
out.extend([" try validate()", " }", "", " public init(from decoder: Decoder) throws {"])
out.append(" let all = try decoder.container(keyedBy: AnyCodingKey.self)")
out.append(" for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: \"unknown_field\") }")
out.append(" let c = try decoder.container(keyedBy: CodingKeys.self)") out.append(" let c = try decoder.container(keyedBy: CodingKeys.self)")
decoded: list[str] = []
for prop_name, prop in definition.get("properties", {}).items(): for prop_name, prop in definition.get("properties", {}).items():
field = swift_field(prop_name) field = swift_field(prop_name)
typ = swift_type(prop) typ = swift_type(prop)
if prop_name in required: if prop_name in required:
out.append(f" {field} = try c.decode({typ}.self, forKey: .{field})") decoded.append(f"{field}: try c.decode({typ}.self, forKey: .{field})")
elif ref_name(prop):
decoded.append(f"{field}: try c.contains(.{field}) ? c.decode({typ}.self, forKey: .{field}) : nil")
else: else:
out.append(f" {field} = try c.decodeIfPresent({typ}.self, forKey: .{field})") decoded.append(f"{field}: try c.decodeIfPresent({typ}.self, forKey: .{field})")
out.extend([" }", "}", ""]) out.append(f" try self.init({', '.join(decoded)})")
out.extend([" }", "", " public func validate() throws {"])
out.extend(swift_validation(definition))
out.extend([" }", "", " 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) }", "}", ""])
out.extend([
"public func deviceRegistrationProofTranscript(serverID: Data, principalID: Data, deviceID: Data, challenge: Data, expiryUnixMilliseconds: Int64) throws -> Data {",
" for (field, value, length) in [(\"server_id\", serverID, 16), (\"principal_id\", principalID, 16), (\"device_id\", deviceID, 16), (\"challenge\", challenge, 32)] {",
" if value.count != length { throw ContractValidationError(field: field, code: \"invalid_length\") }",
" }",
" if expiryUnixMilliseconds < 0 { throw ContractValidationError(field: \"expiry_unix_milliseconds\", code: \"minimum\") }",
" var transcript = Data(\"versevdi-device-proof-v1\".utf8)",
" transcript.append(serverID)",
" transcript.append(principalID)",
" transcript.append(deviceID)",
" transcript.append(challenge)",
" var expiry = UInt64(expiryUnixMilliseconds).bigEndian",
" Swift.withUnsafeBytes(of: &expiry) { transcript.append(contentsOf: $0) }",
" return transcript",
"}",
"",
"public extension TunnelAdmissionRequest {",
" func deviceAdmissionTranscript() -> Data {",
" var fields = [sessionId, gatewayId, audience, grant, String(reconnectSequence), clientNonce, capabilities.transport, capabilities.framing, capabilities.media, capabilities.audio, capabilities.sourceRateControl, String(capabilities.clientDecode.count)]",
" fields.append(contentsOf: capabilities.clientDecode)",
" var transcript = \"versevdi/tunnel-admission/v1\"",
" for field in fields { transcript += \"\\(field.utf8.count):\\(field)\" }",
" return Data(transcript.utf8)",
" }",
"}",
"",
"public extension CapabilityProfile {",
" static func intersection(_ profiles: [CapabilityProfile]) throws -> CapabilityProfile {",
" guard let selected = profiles.first else { throw ContractValidationError(field: \"capabilities\", code: \"no_overlap\") }",
" try selected.validate()",
" var common = selected.clientDecode",
" for profile in profiles.dropFirst() {",
" try profile.validate()",
" if profile.transport != selected.transport || profile.framing != selected.framing || profile.media != selected.media || profile.audio != selected.audio || profile.sourceRateControl != selected.sourceRateControl { throw ContractValidationError(field: \"capabilities\", code: \"no_overlap\") }",
" common = common.filter { profile.clientDecode.contains($0) }",
" if common.isEmpty { throw ContractValidationError(field: \"capabilities\", code: \"no_overlap\") }",
" }",
" return try CapabilityProfile(transport: selected.transport, framing: selected.framing, media: selected.media, audio: selected.audio, sourceRateControl: selected.sourceRateControl, clientDecode: common)",
" }",
"}",
"",
])
return "\n".join(out) return "\n".join(out)
+290 -22
View File
@@ -9,15 +9,11 @@ import (
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"unicode/utf8"
protocol "git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol/gen/go/protocol" protocol "git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol/gen/go/protocol"
) )
const (
datagramHeaderBytes = 21
maximumFrameBytes = 65536
)
func main() { func main() {
entries, err := os.ReadDir("fixtures/conformance") entries, err := os.ReadDir("fixtures/conformance")
if err != nil { if err != nil {
@@ -42,7 +38,7 @@ func main() {
if len(fields) != 5 { if len(fields) != 5 {
panic("invalid fixture row") panic("invalid fixture row")
} }
actual := evaluate(fields[2], fields[3]) actual := evaluate(fields[1], fields[2], fields[3])
if actual != fields[4] { if actual != fields[4] {
panic(fmt.Sprintf("%s: got %s want %s", fields[0], actual, fields[4])) panic(fmt.Sprintf("%s: got %s want %s", fields[0], actual, fields[4]))
} }
@@ -53,7 +49,7 @@ func main() {
fmt.Printf("Go conformance passed normalized=%s fixtures=%s\n", normalizedDigest(results), fixtureHash) fmt.Printf("Go conformance passed normalized=%s fixtures=%s\n", normalizedDigest(results), fixtureHash)
} }
func evaluate(kind, input string) string { func evaluate(version, kind, input string) string {
parts := map[string]string{} parts := map[string]string{}
for _, item := range strings.Split(input, ";") { for _, item := range strings.Split(input, ";") {
pair := strings.SplitN(item, "=", 2) pair := strings.SplitN(item, "=", 2)
@@ -62,8 +58,22 @@ func evaluate(kind, input string) string {
} }
} }
switch kind { switch kind {
case "device_proof_transcript":
serverID, serverErr := hex.DecodeString(parts["server_id"])
principalID, principalErr := hex.DecodeString(parts["principal_id"])
deviceID, deviceErr := hex.DecodeString(parts["device_id"])
challenge, challengeErr := hex.DecodeString(parts["challenge"])
expiry, expiryErr := strconv.ParseInt(parts["expiry_unix_ms"], 10, 64)
if serverErr != nil || principalErr != nil || deviceErr != nil || challengeErr != nil || expiryErr != nil {
return "invalid:fixture"
}
transcript, err := protocol.DeviceRegistrationProofTranscript(serverID, principalID, deviceID, challenge, expiry)
if err != nil {
return "invalid:device_proof"
}
return hex.EncodeToString(transcript)
case "version": case "version":
if input == "1" || input == "0" || input == "-1" { if input == "2" || input == "1" || input == "0" {
return "valid" return "valid"
} }
return "invalid:unsupported_version" return "invalid:unsupported_version"
@@ -85,11 +95,11 @@ func evaluate(kind, input string) string {
Version: parts["version"], Purpose: parts["purpose"], SessionID: "session-1", Version: parts["version"], Purpose: parts["purpose"], SessionID: "session-1",
ReconnectSequence: 0, ReconnectSequence: 0,
Gateway: protocol.ManifestGateway{ Gateway: protocol.ManifestGateway{
ID: parts["gateway_id"], Addresses: []string{"gateway.control.test:443"}, PublicIdentity: parts["gateway_id"], ID: parts["gateway_id"], Addresses: []string{"gateway.control.test:443"}, PublicIdentity: parts["public_identity"],
}, },
Tunnel: protocol.ManifestTunnel{Versions: []string{parts["protocol"] + "/1"}, Features: []string{"control.v1"}}, Tunnel: protocol.ManifestTunnel{Versions: []string{parts["protocol"] + "/1"}, Features: []string{"control.v1"}},
Profile: protocol.ManifestProfile{ID: "standard", Bounds: protocol.ManifestBounds{MinimumKbps: 1, TargetKbps: 2, MaximumKbps: 3}}, Profile: protocol.ManifestProfile{ID: "standard", Bounds: protocol.ManifestBounds{MinimumKbps: 1, TargetKbps: 2, MaximumKbps: 3}},
Grant: protocol.GrantReference{OpaqueValue: parts["grant"], ExpiresAt: parts["expires_at"], Audience: parts["audience"]}, Grant: protocol.GrantReference{OpaqueValue: parts["grant"], ExpiresAt: parts["expires_at"], Audience: parts["audience"]},
CorrelationID: "correlation-1", CorrelationID: "correlation-1",
} }
if value.Validate() == nil { if value.Validate() == nil {
@@ -103,6 +113,60 @@ func evaluate(kind, input string) string {
return "valid" return "valid"
} }
return "invalid:unsupported_clipboard" return "invalid:unsupported_clipboard"
case "session_request":
if version != "2" {
return "invalid:unsupported_version"
}
if _, supplied := parts["policy_snapshot"]; supplied {
return "invalid:forbidden_field"
}
value := protocol.SessionRequest{
ClientDeviceID: parts["client_device_id"], DeviceKeyID: parts["device_key_id"],
PoolID: parts["pool_id"], IdempotencyKey: parts["idempotency_key"],
}
if value.Validate() == nil {
return "valid"
}
return "invalid:required"
case "browser_authenticated_session":
if _, hasDevice := parts["client_device_id"]; hasDevice {
return "invalid:forbidden_field"
}
if _, hasKey := parts["device_key_id"]; hasKey {
return "invalid:forbidden_field"
}
value := protocol.BrowserAuthenticatedSession{
Username: parts["username"], Provider: parts["provider"], Roles: []string{parts["roles"]},
Role: parts["role"],
}
if value.Validate() == nil {
return "valid"
}
return "invalid:invalid_session"
case "native_authenticated_session":
clientDeviceID, hasDevice := parts["client_device_id"]
deviceKeyID, hasKey := parts["device_key_id"]
if !hasDevice || !hasKey {
return "invalid:required"
}
value := protocol.NativeAuthenticatedSession{
Username: parts["username"], Provider: parts["provider"], Roles: []string{parts["roles"]}, Role: parts["role"],
NativeIdentity: protocol.NativeSessionIdentity{ClientDeviceID: clientDeviceID, DeviceKeyID: deviceKeyID},
}
if value.Validate() == nil {
return "valid"
}
return "invalid:invalid_session"
case "native_tunnel_credential":
value := protocol.NativeTunnelCredential{
ClientDeviceID: parts["client_device_id"], DeviceKeyID: parts["device_key_id"],
CertificateChainPem: parts["certificate_chain_pem"], TrustBundlePem: parts["trust_bundle_pem"],
ExpiresAt: parts["expires_at"],
}
if value.Validate() == nil {
return "valid"
}
return "invalid:invalid_credential"
case "event": case "event":
sequence, sequenceErr := strconv.ParseInt(parts["sequence"], 10, 64) sequence, sequenceErr := strconv.ParseInt(parts["sequence"], 10, 64)
payloadBytes, payloadErr := strconv.Atoi(parts["payload_bytes"]) payloadBytes, payloadErr := strconv.Atoi(parts["payload_bytes"])
@@ -118,7 +182,7 @@ func evaluate(kind, input string) string {
} }
value := protocol.EventEnvelope{ value := protocol.EventEnvelope{
EventID: "event-1", Sequence: sequence, Type: "broker.session.changed", Version: 1, EventID: "event-1", Sequence: sequence, Type: "broker.session.changed", Version: 1,
Resource: protocol.ResourceLink{Type: "broker_session", ID: "session-1", Version: 1}, Resource: protocol.ResourceLink{Type: "broker_session", ID: "session-1", Version: 1},
OccurredAt: "2099-01-01T00:00:00Z", CorrelationID: parts["correlation_id"], Payload: map[string]any{}, OccurredAt: "2099-01-01T00:00:00Z", CorrelationID: parts["correlation_id"], Payload: map[string]any{},
} }
if payloadErr != nil || payloadBytes > 16384 { if payloadErr != nil || payloadBytes > 16384 {
@@ -129,35 +193,229 @@ func evaluate(kind, input string) string {
} }
return "valid" return "valid"
case "tunnel": case "tunnel":
if (parts["offered"] == "1" || parts["offered"] == "0" || parts["offered"] == "-1") && parts["feature"] == "control.v1" { feature := parts["feature"]
registered := feature == "control.v1" || feature == "control.v2" || feature == "display.request.v1" || feature == "input.absolute.v1" || feature == "input.scroll.v1"
if (parts["offered"] == "2" || parts["offered"] == "1" || parts["offered"] == "0") && registered {
return "valid" return "valid"
} }
if parts["feature"] != "control.v1" { if !registered {
return "invalid:unsupported_feature" return "invalid:unsupported_feature"
} }
return "invalid:unsupported_version" return "invalid:unsupported_version"
case "datagram": case "datagram":
return classifyDatagram(parts["hex"]) 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: default:
return "invalid:unknown_kind" 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"
}
}
}
case 6:
if len(body) != 8 {
return "invalid:length"
}
x, y := uint16(body[0])<<8|uint16(body[1]), uint16(body[2])<<8|uint16(body[3])
width, height := uint16(body[4])<<8|uint16(body[5]), uint16(body[6])<<8|uint16(body[7])
if width == 0 || height == 0 || x >= width || y >= height {
return "invalid:field"
}
case 7:
if len(body) != 4 {
return "invalid:length"
}
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"
case 3:
if len(body) == 0 {
return "valid"
}
default:
return "invalid:type"
}
return "invalid:length"
}
if kind == 1 || kind == 2 || kind == 3 {
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 { func classifyDatagram(encoded string) string {
raw, err := hex.DecodeString(encoded) raw, err := hex.DecodeString(encoded)
if err != nil { if err != nil {
return "invalid:hex" return "invalid:hex"
} }
if len(raw) < datagramHeaderBytes { if len(raw) < 3 {
return "invalid:truncated" return "invalid:truncated"
} }
if string(raw[:2]) != "VD" { if string(raw[:2]) != "VD" {
return "invalid:magic" return "invalid:magic"
} }
if raw[2] != 1 { if raw[2] != 1 && raw[2] != 2 {
return "invalid:unsupported_version" return "invalid:unsupported_version"
} }
limits := map[byte]int{1: 1024, 2: 2048, 3: 65515} headerBytes := 21
limits := map[byte]int{1: 1024, 2: 2048, 3: 65515, 10: 1179, 11: 1179, 12: 1179}
if raw[2] == 2 {
headerBytes = 23
limits = map[byte]int{10: 1177, 11: 1177}
}
if len(raw) < headerBytes {
return "invalid:truncated"
}
limit, ok := limits[raw[3]] limit, ok := limits[raw[3]]
if !ok { if !ok {
return "invalid:unknown_channel" return "invalid:unknown_channel"
@@ -165,17 +423,27 @@ func classifyDatagram(encoded string) string {
if raw[4] != 0 { if raw[4] != 0 {
return "invalid:flags" return "invalid:flags"
} }
if raw[18] == 0 || raw[17] >= raw[18] { fragmentIndex, fragmentCount := int(raw[17]), int(raw[18])
payloadOffset := 19
if raw[2] == 2 {
fragmentIndex = int(raw[17])<<8 | int(raw[18])
fragmentCount = int(raw[19])<<8 | int(raw[20])
payloadOffset = 21
if fragmentCount > 891 {
return "invalid:fragment_limit"
}
}
if fragmentCount == 0 || fragmentIndex >= fragmentCount {
return "invalid:fragment" return "invalid:fragment"
} }
payloadLength := int(raw[19])<<8 | int(raw[20]) payloadLength := int(raw[payloadOffset])<<8 | int(raw[payloadOffset+1])
if payloadLength > limit { if payloadLength > limit {
return "invalid:payload_limit" return "invalid:payload_limit"
} }
if len(raw) != datagramHeaderBytes+payloadLength { if len(raw) != headerBytes+payloadLength {
return "invalid:length_mismatch" return "invalid:length_mismatch"
} }
if len(raw) > maximumFrameBytes { if raw[2] == 1 && len(raw) > 65536 || raw[2] == 2 && len(raw) > 1200 {
return "invalid:frame_limit" return "invalid:frame_limit"
} }
return "valid" return "valid"
+226 -16
View File
@@ -9,10 +9,10 @@ fn values(input: &str) -> std::collections::BTreeMap<String, String> {
.collect() .collect()
} }
fn evaluate(kind: &str, input: &str) -> &'static str { fn evaluate(version: &str, kind: &str, input: &str) -> &'static str {
let values = values(input); let values = values(input);
match kind { match kind {
"version" if matches!(input, "1" | "0" | "-1") => "valid", "version" if matches!(input, "2" | "1" | "0") => "valid",
"version" => "invalid:unsupported_version", "version" => "invalid:unsupported_version",
"page" => match values.get("limit").and_then(|value| value.parse::<i64>().ok()) { "page" => match values.get("limit").and_then(|value| value.parse::<i64>().ok()) {
Some(limit) if (1..=100).contains(&limit) => "valid", Some(limit) if (1..=100).contains(&limit) => "valid",
@@ -24,12 +24,62 @@ fn evaluate(kind: &str, input: &str) -> &'static str {
"manifest" "manifest"
if values.get("version").map(String::as_str) == Some("1") if values.get("version").map(String::as_str) == Some("1")
&& values.contains_key("gateway_id") && values.contains_key("gateway_id")
&& values.contains_key("public_identity")
&& values.get("grant").map_or(false, |value| value.len() >= 43) && values.get("grant").map_or(false, |value| value.len() >= 43)
&& values.get("purpose").map(String::as_str) == Some("launch") => "valid", && values.get("purpose").map(String::as_str) == Some("launch") => "valid",
"manifest" => "invalid:invalid_manifest", "manifest" => "invalid:invalid_manifest",
"clipboard" if values.get("encoding").map(String::as_str) == Some("utf-8") "clipboard" if values.get("encoding").map(String::as_str) == Some("utf-8")
&& !values.contains_key("file") => "valid", && !values.contains_key("file") => "valid",
"clipboard" => "invalid:unsupported_clipboard", "clipboard" => "invalid:unsupported_clipboard",
"session_request" if version != "2" => "invalid:unsupported_version",
"session_request" if values.contains_key("policy_snapshot") => "invalid:forbidden_field",
"session_request" => match SessionRequest::new(
values.get("client_device_id").cloned().unwrap_or_default(),
values.get("device_key_id").cloned().unwrap_or_default(),
values.get("pool_id").cloned().unwrap_or_default(),
values.get("idempotency_key").cloned().unwrap_or_default(),
None,
) {
Ok(_) => "valid",
Err(_) => "invalid:required",
},
"browser_authenticated_session" if values.contains_key("client_device_id") || values.contains_key("device_key_id") => "invalid:forbidden_field",
"browser_authenticated_session" => match BrowserAuthenticatedSession::new(
values.get("username").cloned().unwrap_or_default(),
values.get("provider").cloned().unwrap_or_default(),
vec![values.get("roles").cloned().unwrap_or_default()],
values.get("role").cloned().unwrap_or_default(),
) {
Ok(_) => "valid",
Err(_) => "invalid:invalid_session",
},
"native_authenticated_session" if !values.contains_key("client_device_id") || !values.contains_key("device_key_id") => "invalid:required",
"native_authenticated_session" => {
let identity = match NativeSessionIdentity::new(values["client_device_id"].clone(), values["device_key_id"].clone()) {
Ok(identity) => identity,
Err(_) => return "invalid:required",
};
match NativeAuthenticatedSession::new(
values.get("username").cloned().unwrap_or_default(),
values.get("provider").cloned().unwrap_or_default(),
vec![values.get("roles").cloned().unwrap_or_default()],
values.get("role").cloned().unwrap_or_default(),
identity,
) {
Ok(_) => "valid",
Err(_) => "invalid:invalid_session",
}
}
"native_tunnel_credential" => match NativeTunnelCredential::new(
values.get("client_device_id").cloned().unwrap_or_default(),
values.get("device_key_id").cloned().unwrap_or_default(),
values.get("certificate_chain_pem").cloned().unwrap_or_default(),
values.get("trust_bundle_pem").cloned().unwrap_or_default(),
values.get("expires_at").cloned().unwrap_or_default(),
) {
Ok(_) => "valid",
Err(_) => "invalid:invalid_credential",
},
"event" if values.get("version").map(String::as_str) != Some("1") => { "event" if values.get("version").map(String::as_str) != Some("1") => {
"invalid:unsupported_version" "invalid:unsupported_version"
} }
@@ -46,17 +96,158 @@ fn evaluate(kind: &str, input: &str) -> &'static str {
"event" if values.get("sequence").and_then(|value| value.parse::<i64>().ok()).map_or(true, |sequence| sequence < 1) "event" if values.get("sequence").and_then(|value| value.parse::<i64>().ok()).map_or(true, |sequence| sequence < 1)
|| !values.contains_key("correlation_id") => "invalid:required", || !values.contains_key("correlation_id") => "invalid:required",
"event" => "valid", "event" => "valid",
"tunnel" if matches!(values.get("offered").map(String::as_str), Some("1") | Some("0") | Some("-1")) "tunnel" if matches!(values.get("offered").map(String::as_str), Some("2") | Some("1") | Some("0"))
&& values.get("feature").map(String::as_str) == Some("control.v1") => "valid", && matches!(values.get("feature").map(String::as_str), Some("control.v1") | Some("control.v2") | Some("display.request.v1") | Some("input.absolute.v1") | Some("input.scroll.v1")) => "valid",
"tunnel" if values.get("feature").map(String::as_str) != Some("control.v1") => { "tunnel" if !matches!(values.get("feature").map(String::as_str), Some("control.v1") | Some("control.v2") | Some("display.request.v1") | Some("input.absolute.v1") | Some("input.scroll.v1")) => {
"invalid:unsupported_feature" "invalid:unsupported_feature"
} }
"tunnel" => "invalid:unsupported_version", "tunnel" => "invalid:unsupported_version",
"datagram" => classify_datagram(values.get("hex").map(String::as_str).unwrap_or_default()), "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", _ => "invalid:unknown_kind",
} }
} }
fn evaluate_device_proof(input: &str) -> String {
let values = values(input);
let server_id = decode_hex(values.get("server_id").map(String::as_str).unwrap_or_default()).expect("server fixture hex");
let principal_id = decode_hex(values.get("principal_id").map(String::as_str).unwrap_or_default()).expect("principal fixture hex");
let device_id = decode_hex(values.get("device_id").map(String::as_str).unwrap_or_default()).expect("device fixture hex");
let challenge = decode_hex(values.get("challenge").map(String::as_str).unwrap_or_default()).expect("challenge fixture hex");
let expiry = values.get("expiry_unix_ms").expect("expiry fixture").parse::<i64>().expect("expiry integer");
device_registration_proof_transcript(&server_id, &principal_id, &device_id, &challenge, expiry)
.expect("valid device proof fixture")
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
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",
6 if body.len() != 8 => "invalid:length",
6 => {
let x = u16::from_be_bytes([body[0], body[1]]);
let y = u16::from_be_bytes([body[2], body[3]]);
let width = u16::from_be_bytes([body[4], body[5]]);
let height = u16::from_be_bytes([body[6], body[7]]);
if width != 0 && height != 0 && x < width && y < height { "valid" } else { "invalid:field" }
}
7 if body.len() == 4 => "valid",
7 => "invalid:length",
_ => "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",
3 if body.is_empty() => "valid",
3 => "invalid:length",
_ => "invalid:type",
};
}
if kind == 1 || kind == 2 || kind == 3 {
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>> { fn decode_hex(input: &str) -> Option<Vec<u8>> {
if input.len() % 2 != 0 { if input.len() % 2 != 0 {
return None; return None;
@@ -72,35 +263,50 @@ fn classify_datagram(encoded: &str) -> &'static str {
Some(raw) => raw, Some(raw) => raw,
None => return "invalid:hex", None => return "invalid:hex",
}; };
if raw.len() < 21 { if raw.len() < 3 {
return "invalid:truncated"; return "invalid:truncated";
} }
if raw[0..2] != *b"VD" { if raw[0..2] != *b"VD" {
return "invalid:magic"; return "invalid:magic";
} }
if raw[2] != 1 { if raw[2] != 1 && raw[2] != 2 {
return "invalid:unsupported_version"; return "invalid:unsupported_version";
} }
let limit = match raw[3] { let header_bytes = if raw[2] == 1 { 21 } else { 23 };
1 => 1024, if raw.len() < header_bytes {
2 => 2048, return "invalid:truncated";
3 => 65515, }
let limit = match (raw[2], raw[3]) {
(1, 1) => 1024,
(1, 2) => 2048,
(1, 3) => 65515,
(1, 10 | 11 | 12) => 1179,
(2, 10 | 11) => 1177,
_ => return "invalid:unknown_channel", _ => return "invalid:unknown_channel",
}; };
if raw[4] != 0 { if raw[4] != 0 {
return "invalid:flags"; return "invalid:flags";
} }
if raw[18] == 0 || raw[17] >= raw[18] { let (fragment_index, fragment_count, payload_offset) = if raw[2] == 1 {
(raw[17] as usize, raw[18] as usize, 19)
} else {
let count = ((raw[19] as usize) << 8) | raw[20] as usize;
if count > 891 {
return "invalid:fragment_limit";
}
((((raw[17] as usize) << 8) | raw[18] as usize), count, 21)
};
if fragment_count == 0 || fragment_index >= fragment_count {
return "invalid:fragment"; return "invalid:fragment";
} }
let payload_length = ((raw[19] as usize) << 8) | raw[20] as usize; let payload_length = ((raw[payload_offset] as usize) << 8) | raw[payload_offset + 1] as usize;
if payload_length > limit { if payload_length > limit {
return "invalid:payload_limit"; return "invalid:payload_limit";
} }
if raw.len() != 21 + payload_length { if raw.len() != header_bytes + payload_length {
return "invalid:length_mismatch"; return "invalid:length_mismatch";
} }
if raw.len() > 65536 { if raw[2] == 1 && raw.len() > 65536 || raw[2] == 2 && raw.len() > 1200 {
return "invalid:frame_limit"; return "invalid:frame_limit";
} }
"valid" "valid"
@@ -141,7 +347,11 @@ fn main() {
for line in lines { for line in lines {
let fields: Vec<&str> = line.split('\t').collect(); let fields: Vec<&str> = line.split('\t').collect();
assert_eq!(fields.len(), 5); assert_eq!(fields.len(), 5);
let actual = evaluate(fields[2], fields[3]); let actual = if fields[2] == "device_proof_transcript" {
evaluate_device_proof(fields[3])
} else {
evaluate(fields[1], fields[2], fields[3]).to_owned()
};
assert_eq!(actual, fields[4], "{}", fields[0]); assert_eq!(actual, fields[4], "{}", fields[0]);
results.push(format!("{}\t{}", fields[0], actual)); results.push(format!("{}\t{}", fields[0], actual));
} }
+170 -19
View File
@@ -9,17 +9,49 @@ func values(_ input: String) -> [String: String] {
return result return result
} }
func evaluate(_ kind: String, _ input: String) -> String { func evaluate(_ version: String, _ kind: String, _ input: String) -> String {
let values = values(input) let values = values(input)
switch kind { switch kind {
case "version": return ["1", "0", "-1"].contains(input) ? "valid" : "invalid:unsupported_version" case "version": return ["2", "1", "0"].contains(input) ? "valid" : "invalid:unsupported_version"
case "page": case "page":
guard let raw = values["limit"], let limit = Int(raw), (1...100).contains(limit) else { return "invalid:invalid_limit" } guard let raw = values["limit"], let limit = Int(raw), (1...100).contains(limit) else { return "invalid:invalid_limit" }
return "valid" return "valid"
case "manifest": case "manifest":
for key in ["provider_url", "vm_address", "password", "private_key"] where values[key] != nil { return "invalid:forbidden_field" } for key in ["provider_url", "vm_address", "password", "private_key"] where values[key] != nil { return "invalid:forbidden_field" }
return values["version"] == "1" && values["gateway_id"] != nil && (values["grant"]?.utf8.count ?? 0) >= 43 && values["purpose"] == "launch" ? "valid" : "invalid:invalid_manifest" return values["version"] == "1" && values["gateway_id"] != nil && values["public_identity"] != nil && (values["grant"]?.utf8.count ?? 0) >= 43 && values["purpose"] == "launch" ? "valid" : "invalid:invalid_manifest"
case "clipboard": return values["encoding"] == "utf-8" && values["file"] == nil ? "valid" : "invalid:unsupported_clipboard" case "clipboard": return values["encoding"] == "utf-8" && values["file"] == nil ? "valid" : "invalid:unsupported_clipboard"
case "session_request":
guard version == "2" else { return "invalid:unsupported_version" }
if values["policy_snapshot"] != nil { return "invalid:forbidden_field" }
guard (try? SessionRequest(
clientDeviceId: values["client_device_id"] ?? "", deviceKeyId: values["device_key_id"] ?? "",
poolId: values["pool_id"] ?? "", idempotencyKey: values["idempotency_key"] ?? "",
requestedDisplayMode: nil
)) != nil else { return "invalid:required" }
return "valid"
case "browser_authenticated_session":
guard values["client_device_id"] == nil, values["device_key_id"] == nil else { return "invalid:forbidden_field" }
guard (try? BrowserAuthenticatedSession(
username: values["username"] ?? "", provider: values["provider"] ?? "",
roles: [values["roles"] ?? ""], role: values["role"] ?? ""
)) != nil else { return "invalid:invalid_session" }
return "valid"
case "native_authenticated_session":
guard let identity = try? NativeSessionIdentity(
clientDeviceId: values["client_device_id"] ?? "", deviceKeyId: values["device_key_id"] ?? ""
), values["client_device_id"] != nil, values["device_key_id"] != nil else { return "invalid:required" }
guard (try? NativeAuthenticatedSession(
username: values["username"] ?? "", provider: values["provider"] ?? "",
roles: [values["roles"] ?? ""], role: values["role"] ?? "", nativeIdentity: identity
)) != nil else { return "invalid:invalid_session" }
return "valid"
case "native_tunnel_credential":
guard (try? NativeTunnelCredential(
clientDeviceId: values["client_device_id"] ?? "", deviceKeyId: values["device_key_id"] ?? "",
certificateChainPem: values["certificate_chain_pem"] ?? "", trustBundlePem: values["trust_bundle_pem"] ?? "",
expiresAt: values["expires_at"] ?? ""
)) != nil else { return "invalid:invalid_credential" }
return "valid"
case "event": case "event":
guard values["version"] == "1" else { return "invalid:unsupported_version" } guard values["version"] == "1" else { return "invalid:unsupported_version" }
if let after = Int(values["after"] ?? ""), let earliest = Int(values["earliest"] ?? ""), after > 0, earliest > 0, after < earliest - 1 { return "invalid:gap" } if let after = Int(values["after"] ?? ""), let earliest = Int(values["earliest"] ?? ""), after > 0, earliest > 0, after < earliest - 1 { return "invalid:gap" }
@@ -27,37 +59,154 @@ func evaluate(_ kind: String, _ input: String) -> String {
guard let sequence = Int(values["sequence"] ?? ""), sequence > 0, values["correlation_id"] != nil else { return "invalid:required" } guard let sequence = Int(values["sequence"] ?? ""), sequence > 0, values["correlation_id"] != nil else { return "invalid:required" }
return "valid" return "valid"
case "tunnel": case "tunnel":
if ["1", "0", "-1"].contains(values["offered"] ?? "") && values["feature"] == "control.v1" { return "valid" } let registered = ["control.v1", "control.v2", "display.request.v1", "input.absolute.v1", "input.scroll.v1"].contains(values["feature"] ?? "")
return values["feature"] == "control.v1" ? "invalid:unsupported_version" : "invalid:unsupported_feature" if ["2", "1", "0"].contains(values["offered"] ?? "") && registered { return "valid" }
return registered ? "invalid:unsupported_version" : "invalid:unsupported_feature"
case "datagram": return classifyDatagram(values["hex"] ?? "") 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" default: return "invalid:unknown_kind"
} }
} }
func classifyDatagram(_ encoded: String) -> String { func evaluateDeviceProof(_ input: String) -> String {
let values = values(input)
let serverID = Data(decodeHex(values["server_id"] ?? "")!)
let principalID = Data(decodeHex(values["principal_id"] ?? "")!)
let deviceID = Data(decodeHex(values["device_id"] ?? "")!)
let challenge = Data(decodeHex(values["challenge"] ?? "")!)
let expiry = Int64(values["expiry_unix_ms"] ?? "")!
return try! deviceRegistrationProofTranscript(
serverID: serverID, principalID: principalID, deviceID: deviceID,
challenge: challenge, expiryUnixMilliseconds: expiry
).map { String(format: "%02x", $0) }.joined()
}
func decodeHex(_ encoded: String) -> [UInt8]? {
let characters = Array(encoded) let characters = Array(encoded)
guard characters.count % 2 == 0 else { return "invalid:hex" } guard characters.count % 2 == 0 else { return nil }
var raw: [UInt8] = [] var raw: [UInt8] = []
for index in stride(from: 0, to: characters.count, by: 2) { 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) raw.append(byte)
} }
guard raw.count >= 21 else { return "invalid:truncated" } 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"
case 6:
guard body.count == 8 else { return "invalid:length" }
let x = Int(body[0]) * 256 + Int(body[1])
let y = Int(body[2]) * 256 + Int(body[3])
let width = Int(body[4]) * 256 + Int(body[5])
let height = Int(body[6]) * 256 + Int(body[7])
return width > 0 && height > 0 && x < width && y < height ? "valid" : "invalid:field"
case 7: return body.count == 4 ? "valid" : "invalid:length"
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"
case 3: return body.isEmpty ? "valid" : "invalid:length"
default: return "invalid:type"
}
}
if kind == 1 || kind == 2 || kind == 3 { 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 >= 3 else { return "invalid:truncated" }
guard raw[0] == 0x56 && raw[1] == 0x44 else { return "invalid:magic" } guard raw[0] == 0x56 && raw[1] == 0x44 else { return "invalid:magic" }
guard raw[2] == 1 else { return "invalid:unsupported_version" } guard raw[2] == 1 || raw[2] == 2 else { return "invalid:unsupported_version" }
let headerBytes = raw[2] == 1 ? 21 : 23
guard raw.count >= headerBytes else { return "invalid:truncated" }
let limit: Int let limit: Int
switch raw[3] { switch (raw[2], raw[3]) {
case 1: limit = 1024 case (1, 1): limit = 1024
case 2: limit = 2048 case (1, 2): limit = 2048
case 3: limit = 65515 case (1, 3): limit = 65515
case (1, 10), (1, 11), (1, 12): limit = 1179
case (2, 10), (2, 11): limit = 1177
default: return "invalid:unknown_channel" default: return "invalid:unknown_channel"
} }
guard raw[4] == 0 else { return "invalid:flags" } guard raw[4] == 0 else { return "invalid:flags" }
guard raw[18] > 0 && raw[17] < raw[18] else { return "invalid:fragment" } let fragmentIndex = raw[2] == 1 ? Int(raw[17]) : Int(raw[17]) * 256 + Int(raw[18])
let payloadLength = Int(raw[19]) * 256 + Int(raw[20]) let fragmentCount = raw[2] == 1 ? Int(raw[18]) : Int(raw[19]) * 256 + Int(raw[20])
if raw[2] == 2 && fragmentCount > 891 { return "invalid:fragment_limit" }
guard fragmentCount > 0 && fragmentIndex < fragmentCount else { return "invalid:fragment" }
let payloadOffset = raw[2] == 1 ? 19 : 21
let payloadLength = Int(raw[payloadOffset]) * 256 + Int(raw[payloadOffset + 1])
guard payloadLength <= limit else { return "invalid:payload_limit" } guard payloadLength <= limit else { return "invalid:payload_limit" }
guard raw.count == 21 + payloadLength else { return "invalid:length_mismatch" } guard raw.count == headerBytes + payloadLength else { return "invalid:length_mismatch" }
guard raw.count <= 65536 else { return "invalid:frame_limit" } guard raw[2] == 1 ? raw.count <= 65536 : raw.count <= 1200 else { return "invalid:frame_limit" }
return "valid" return "valid"
} }
@@ -93,7 +242,9 @@ struct ConformanceMain {
for line in lines { for line in lines {
let fields = line.split(separator: "\t", omittingEmptySubsequences: false).map(String.init) let fields = line.split(separator: "\t", omittingEmptySubsequences: false).map(String.init)
precondition(fields.count == 5) precondition(fields.count == 5)
let actual = evaluate(fields[2], fields[3]) let actual = fields[2] == "device_proof_transcript"
? evaluateDeviceProof(fields[3])
: evaluate(fields[1], fields[2], fields[3])
precondition(actual == fields[4], fields[0]) precondition(actual == fields[4], fields[0])
results.append("\(fields[0])\t\(actual)") results.append("\(fields[0])\t\(actual)")
} }
+8 -1
View File
@@ -20,7 +20,14 @@ def main() -> int:
temp = pathlib.Path(directory) temp = pathlib.Path(directory)
rust_bin = temp / "rust-conformance" rust_bin = temp / "rust-conformance"
swift_bin = temp / "swift-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)]) run([str(rust_bin)])
main_source = temp / "main.swift" main_source = temp / "main.swift"
main_source.write_text((ROOT / "tools/native_conformance.swift").read_text(encoding="utf-8"), encoding="utf-8") main_source.write_text((ROOT / "tools/native_conformance.swift").read_text(encoding="utf-8"), encoding="utf-8")

Some files were not shown because too many files have changed in this diff Show More