Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4693102b3c | ||
|
|
afbcea62f9 | ||
|
|
b6a4f773e4 | ||
|
|
afcd5d99db | ||
|
|
79d9e49497 | ||
|
|
408d4f9cc3 | ||
|
|
346bf5fe4d | ||
|
|
7c89ef5bf5 |
+1
-1
@@ -21,10 +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
|
# IDE files
|
||||||
/.idea
|
/.idea
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ go-test:
|
|||||||
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:
|
strict-contracts:
|
||||||
$(PYTHON) -B tools/test_generated_contracts.py
|
$(PYTHON) -B tools/test_generated_contracts.py
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
|
@@ -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
|
||||||
|
@@ -7,6 +7,8 @@ valid-relative-mouse 1 gateway_input hex=564749310304fffe0003 valid
|
|||||||
valid-utf8-scalar 1 gateway_input hex=564749310403e29883 valid
|
valid-utf8-scalar 1 gateway_input hex=564749310403e29883 valid
|
||||||
valid-controller 1 gateway_input hex=5647493105110200030004ffff00010002000300040005 valid
|
valid-controller 1 gateway_input hex=5647493105110200030004ffff00010002000300040005 valid
|
||||||
valid-controller-release 1 gateway_input hex=5647493105110200000000000000000000000000000000 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-idr 1 gateway_feedback hex=5647463100010000 valid
|
||||||
valid-fec 1 gateway_feedback hex=56474631000200150000002a000500030002000a000200080002140001 valid
|
valid-fec 1 gateway_feedback hex=56474631000200150000002a000500030002000a000200080002140001 valid
|
||||||
valid-terminal-receipt 1 gateway_feedback hex=5647463100030000 valid
|
valid-terminal-receipt 1 gateway_feedback hex=5647463100030000 valid
|
||||||
@@ -18,6 +20,11 @@ invalid-input-kind 1 gateway_input hex=564749317f00 invalid:kind
|
|||||||
invalid-input-reserved 1 gateway_input hex=564749310203010101 invalid:reserved
|
invalid-input-reserved 1 gateway_input hex=564749310203010101 invalid:reserved
|
||||||
invalid-input-utf8 1 gateway_input hex=564749310402c328 invalid:utf8
|
invalid-input-utf8 1 gateway_input hex=564749310402c328 invalid:utf8
|
||||||
invalid-input-length 1 gateway_input hex=564749310104010200 invalid:length
|
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-feedback-direction 1 gateway_feedback hex=5647463101020000 invalid:direction
|
||||||
invalid-terminal-receipt-direction 1 gateway_feedback hex=5647463101030000 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-body 1 gateway_feedback hex=5647463100030001ff invalid:length
|
||||||
|
|||||||
|
@@ -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
|
||||||
|
|||||||
|
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -4,11 +4,12 @@
|
|||||||
"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/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-audit-v1.tsv",
|
||||||
"fixtures/conformance/gateway-clipboard-v1.tsv",
|
"fixtures/conformance/gateway-clipboard-v1.tsv",
|
||||||
"fixtures/conformance/gateway-input-feedback-v1.tsv",
|
"fixtures/conformance/gateway-input-feedback-v1.tsv",
|
||||||
"fixtures/conformance/tunnel-v1.tsv"
|
"fixtures/conformance/tunnel-v1.tsv"
|
||||||
],
|
],
|
||||||
"corpus_sha256": "544ba8765b4ddcfbe807d1d6e6788fe03d06b8e50d1424b82e4e512312e51248"
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"client_device_id": "device-1",
|
||||||
|
"device_key_id": "key-1",
|
||||||
|
"pool_id": "pool-1",
|
||||||
|
"idempotency_key": "request-1"
|
||||||
|
}
|
||||||
@@ -4,12 +4,14 @@ This grammar is carried only in an authenticated Phase 3C gateway session. It
|
|||||||
is deliberately provider-neutral: it never carries provider routes,
|
is deliberately provider-neutral: it never carries provider routes,
|
||||||
certificates, credentials, opaque provider packets, clipboard bytes, files, or
|
certificates, credentials, opaque provider packets, clipboard bytes, files, or
|
||||||
client-folder data. It does not change the v1 datagram header or any existing
|
client-folder data. It does not change the v1 datagram header or any existing
|
||||||
release candidate.
|
kind encoding; the new kinds require explicit feature negotiation and a new
|
||||||
|
immutable Protocol release candidate.
|
||||||
|
|
||||||
## `input.sequenced.v1` payload (`VGI1`)
|
## `input.sequenced.v1` payload (`VGI1`)
|
||||||
|
|
||||||
All multibyte fields are unsigned big-endian. The payload has exactly six bytes
|
All multibyte fields are big-endian and unsigned unless a field is explicitly
|
||||||
of header followed by the declared body:
|
marked signed. The payload has exactly six bytes of header followed by the
|
||||||
|
declared body:
|
||||||
|
|
||||||
| Offset | Size | Field | Rule |
|
| Offset | Size | Field | Rule |
|
||||||
|---:|---:|---|---|
|
|---:|---:|---|---|
|
||||||
@@ -32,12 +34,16 @@ during cleanup.
|
|||||||
| `0x03` | relative mouse | `delta_x` (i16), `delta_y` (i16). |
|
| `0x03` | relative mouse | `delta_x` (i16), `delta_y` (i16). |
|
||||||
| `0x04` | UTF-8 scalar | exactly one valid UTF-8 Unicode scalar, one through four bytes. |
|
| `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. |
|
| `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, and controller messages are delivered over the
|
Keyboard, mouse button, UTF-8, controller, absolute mouse, and scroll messages
|
||||||
gateway's reliable ordered input flow. Relative mouse is a state change, not a
|
are delivered over the gateway's reliable ordered input flow. Relative and
|
||||||
pressed-state entry. The gateway maps the validated values to the provider's
|
absolute mouse movement and scroll are state changes, not pressed-state
|
||||||
separate keyboard, mouse, UTF-8, and controller control messages; it does not
|
entries. Kinds `0x06` and `0x07` are accepted only when the session advertises
|
||||||
forward this envelope to the provider.
|
`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`)
|
## Reliable control payload (`VGF1`)
|
||||||
|
|
||||||
|
|||||||
+760
-34
@@ -4,6 +4,7 @@ package protocol
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -13,11 +14,11 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const SchemaSHA256 = "a86cbdaf2cfb884e3d98467968007e731ca55c6f6eb6dbd5cd6b95e062a9b058"
|
const SchemaSHA256 = "b2353c12269304289b4e872f27cc370ae61b958dea90d9fb7b6ab8afd7d37248"
|
||||||
const ProtocolVersion = "1.0.0"
|
const ProtocolVersion = "1.0.0"
|
||||||
const CurrentWireVersion = "1"
|
const CurrentWireVersion = "2"
|
||||||
const NMinus1WireVersion = "0"
|
const NMinus1WireVersion = "1"
|
||||||
const NMinus2WireVersion = "-1"
|
const NMinus2WireVersion = "0"
|
||||||
|
|
||||||
type FieldViolation struct {
|
type FieldViolation struct {
|
||||||
Field string `json:"field"`
|
Field string `json:"field"`
|
||||||
@@ -50,21 +51,30 @@ type AssignedDesktop struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type BrokerSession struct {
|
type BrokerSession struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
PrincipalID string `json:"principal_id"`
|
PrincipalID string `json:"principal_id"`
|
||||||
PoolID string `json:"pool_id"`
|
PoolID string `json:"pool_id"`
|
||||||
AssignmentID string `json:"assignment_id,omitempty"`
|
AssignmentID string `json:"assignment_id,omitempty"`
|
||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
PolicySnapshot AllocationPolicy `json:"policy_snapshot"`
|
PolicySnapshot AllocationPolicy `json:"policy_snapshot"`
|
||||||
ReconnectDeadline string `json:"reconnect_deadline,omitempty"`
|
ReconnectDeadline string `json:"reconnect_deadline,omitempty"`
|
||||||
Outcome string `json:"outcome,omitempty"`
|
Outcome string `json:"outcome,omitempty"`
|
||||||
FailureCode string `json:"failure_code,omitempty"`
|
FailureCode string `json:"failure_code,omitempty"`
|
||||||
CleanupState string `json:"cleanup_state"`
|
CleanupState string `json:"cleanup_state"`
|
||||||
IdempotencyKey string `json:"idempotency_key"`
|
IdempotencyKey string `json:"idempotency_key"`
|
||||||
CorrelationID string `json:"correlation_id"`
|
CorrelationID string `json:"correlation_id"`
|
||||||
RequestedAt string `json:"requested_at"`
|
RequestedAt string `json:"requested_at"`
|
||||||
EndedAt string `json:"ended_at,omitempty"`
|
EndedAt string `json:"ended_at,omitempty"`
|
||||||
Version int64 `json:"version"`
|
Version int64 `json:"version"`
|
||||||
|
RequestedDisplayMode *DisplayMode `json:"requested_display_mode,omitempty"`
|
||||||
|
EffectiveDisplayMode *DisplayMode `json:"effective_display_mode,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BrowserAuthenticatedSession struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
Roles []string `json:"roles"`
|
||||||
|
Role string `json:"role"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CapabilityProfile struct {
|
type CapabilityProfile struct {
|
||||||
@@ -87,6 +97,16 @@ type ChannelFrame struct {
|
|||||||
Payload string `json:"payload"`
|
Payload string `json:"payload"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ClientSessionAuthority struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
GatewayID string `json:"gateway_id"`
|
||||||
|
Audience string `json:"audience"`
|
||||||
|
ReconnectSequence int64 `json:"reconnect_sequence"`
|
||||||
|
ExpiresAt string `json:"expires_at"`
|
||||||
|
Capabilities CapabilityProfile `json:"capabilities"`
|
||||||
|
}
|
||||||
|
|
||||||
type ClipboardPolicy struct {
|
type ClipboardPolicy struct {
|
||||||
ClientToProviderEnabled bool `json:"client_to_provider_enabled"`
|
ClientToProviderEnabled bool `json:"client_to_provider_enabled"`
|
||||||
ProviderToClientEnabled bool `json:"provider_to_client_enabled"`
|
ProviderToClientEnabled bool `json:"provider_to_client_enabled"`
|
||||||
@@ -134,6 +154,12 @@ type DeviceRegistrationRequest struct {
|
|||||||
PublicKey string `json:"public_key"`
|
PublicKey string `json:"public_key"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DisplayMode struct {
|
||||||
|
ResolutionWidth int64 `json:"resolution_width"`
|
||||||
|
ResolutionHeight int64 `json:"resolution_height"`
|
||||||
|
Fps int64 `json:"fps"`
|
||||||
|
}
|
||||||
|
|
||||||
type EntitledPool struct {
|
type EntitledPool struct {
|
||||||
PoolID string `json:"pool_id"`
|
PoolID string `json:"pool_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -263,8 +289,9 @@ type ManifestGateway struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ManifestProfile struct {
|
type ManifestProfile struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Bounds ManifestBounds `json:"bounds"`
|
Bounds ManifestBounds `json:"bounds"`
|
||||||
|
DisplayMode *DisplayMode `json:"display_mode,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ManifestTunnel struct {
|
type ManifestTunnel struct {
|
||||||
@@ -272,6 +299,14 @@ type ManifestTunnel struct {
|
|||||||
Features []string `json:"features"`
|
Features []string `json:"features"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type NativeAuthenticatedSession struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
Roles []string `json:"roles"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
NativeIdentity NativeSessionIdentity `json:"native_identity"`
|
||||||
|
}
|
||||||
|
|
||||||
type NativeCredential struct {
|
type NativeCredential struct {
|
||||||
DeviceID string `json:"device_id,omitempty"`
|
DeviceID string `json:"device_id,omitempty"`
|
||||||
FamilyID string `json:"family_id"`
|
FamilyID string `json:"family_id"`
|
||||||
@@ -281,6 +316,19 @@ type NativeCredential struct {
|
|||||||
RefreshExpiresAt string `json:"refresh_expires_at,omitempty"`
|
RefreshExpiresAt string `json:"refresh_expires_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type NativeSessionIdentity struct {
|
||||||
|
ClientDeviceID string `json:"client_device_id"`
|
||||||
|
DeviceKeyID string `json:"device_key_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NativeTunnelCredential struct {
|
||||||
|
ClientDeviceID string `json:"client_device_id"`
|
||||||
|
DeviceKeyID string `json:"device_key_id"`
|
||||||
|
CertificateChainPem string `json:"certificate_chain_pem"`
|
||||||
|
TrustBundlePem string `json:"trust_bundle_pem"`
|
||||||
|
ExpiresAt string `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type PageInfo struct {
|
type PageInfo struct {
|
||||||
Limit int64 `json:"limit"`
|
Limit int64 `json:"limit"`
|
||||||
NextCursor string `json:"next_cursor"`
|
NextCursor string `json:"next_cursor"`
|
||||||
@@ -383,11 +431,11 @@ type SessionAuthority struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SessionRequest struct {
|
type SessionRequest struct {
|
||||||
ClientDeviceID string `json:"client_device_id"`
|
ClientDeviceID string `json:"client_device_id"`
|
||||||
DeviceKeyID string `json:"device_key_id"`
|
DeviceKeyID string `json:"device_key_id"`
|
||||||
PoolID string `json:"pool_id"`
|
PoolID string `json:"pool_id"`
|
||||||
IdempotencyKey string `json:"idempotency_key"`
|
IdempotencyKey string `json:"idempotency_key"`
|
||||||
PolicySnapshot AllocationPolicy `json:"policy_snapshot"`
|
RequestedDisplayMode *DisplayMode `json:"requested_display_mode,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type StableError struct {
|
type StableError struct {
|
||||||
@@ -767,6 +815,16 @@ func (v BrokerSession) Validate() error {
|
|||||||
if v.Version != 0 && v.Version < 1 {
|
if v.Version != 0 && v.Version < 1 {
|
||||||
violations = append(violations, FieldViolation{Field: "version", Code: "minimum"})
|
violations = append(violations, FieldViolation{Field: "version", Code: "minimum"})
|
||||||
}
|
}
|
||||||
|
if v.RequestedDisplayMode != nil {
|
||||||
|
if err := v.RequestedDisplayMode.Validate(); err != nil {
|
||||||
|
violations = append(violations, FieldViolation{Field: "requested_display_mode", Code: "invalid_object"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v.EffectiveDisplayMode != nil {
|
||||||
|
if err := v.EffectiveDisplayMode.Validate(); err != nil {
|
||||||
|
violations = append(violations, FieldViolation{Field: "effective_display_mode", Code: "invalid_object"})
|
||||||
|
}
|
||||||
|
}
|
||||||
if len(violations) > 0 {
|
if len(violations) > 0 {
|
||||||
return ValidationError{Violations: violations}
|
return ValidationError{Violations: violations}
|
||||||
}
|
}
|
||||||
@@ -812,6 +870,12 @@ func DecodeBrokerSession(data []byte) (BrokerSession, error) {
|
|||||||
if raw, ok := fields["version"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
if raw, ok := fields["version"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
return value, ValidationError{Violations: []FieldViolation{{Field: "version", Code: "required"}}}
|
return value, ValidationError{Violations: []FieldViolation{{Field: "version", Code: "required"}}}
|
||||||
}
|
}
|
||||||
|
if raw, ok := fields["requested_display_mode"]; ok && bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "requested_display_mode", Code: "invalid_object"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["effective_display_mode"]; ok && bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "effective_display_mode", Code: "invalid_object"}}}
|
||||||
|
}
|
||||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||||
decoder.DisallowUnknownFields()
|
decoder.DisallowUnknownFields()
|
||||||
if err := decoder.Decode(&value); err != nil {
|
if err := decoder.Decode(&value); err != nil {
|
||||||
@@ -837,6 +901,105 @@ func EncodeBrokerSession(value BrokerSession) ([]byte, error) {
|
|||||||
return json.Marshal(value)
|
return json.Marshal(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (v BrowserAuthenticatedSession) Validate() error {
|
||||||
|
var violations []FieldViolation
|
||||||
|
if v.Username == "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "username", Code: "required"})
|
||||||
|
}
|
||||||
|
if len(v.Username) < 1 && v.Username != "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "username", Code: "min_length"})
|
||||||
|
}
|
||||||
|
if len(v.Username) > 256 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "username", Code: "max_length"})
|
||||||
|
}
|
||||||
|
if v.Provider == "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "provider", Code: "required"})
|
||||||
|
}
|
||||||
|
if len(v.Provider) < 1 && v.Provider != "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "provider", Code: "min_length"})
|
||||||
|
}
|
||||||
|
if len(v.Provider) > 64 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "provider", Code: "max_length"})
|
||||||
|
}
|
||||||
|
if v.Roles == nil {
|
||||||
|
violations = append(violations, FieldViolation{Field: "roles", Code: "required"})
|
||||||
|
}
|
||||||
|
if len(v.Roles) > 16 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "roles", Code: "max_items"})
|
||||||
|
}
|
||||||
|
for _, item := range v.Roles {
|
||||||
|
if len(item) < 1 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "roles", Code: "min_item_length"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, item := range v.Roles {
|
||||||
|
if len(item) > 64 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "roles", Code: "max_item_length"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, item := range v.Roles {
|
||||||
|
if len(item) > 64 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "roles", Code: "max_item_bytes"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v.Role == "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "role", Code: "required"})
|
||||||
|
}
|
||||||
|
if v.Role != "" && !(v.Role == "user" || v.Role == "admin") {
|
||||||
|
violations = append(violations, FieldViolation{Field: "role", Code: "invalid_value"})
|
||||||
|
}
|
||||||
|
if len(violations) > 0 {
|
||||||
|
return ValidationError{Violations: violations}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecodeBrowserAuthenticatedSession(data []byte) (BrowserAuthenticatedSession, error) {
|
||||||
|
var value BrowserAuthenticatedSession
|
||||||
|
if len(data) > 1024*1024 {
|
||||||
|
return value, errors.New("protocol payload exceeds limit")
|
||||||
|
}
|
||||||
|
var fields map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(data, &fields); err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
if raw, ok := fields["provider"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "provider", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["role"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "role", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["roles"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "roles", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["username"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "username", Code: "required"}}}
|
||||||
|
}
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(&value); err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
var trailing any
|
||||||
|
if err := decoder.Decode(&trailing); err != io.EOF {
|
||||||
|
if err == nil {
|
||||||
|
return value, errors.New("trailing JSON value")
|
||||||
|
}
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
if err := value.Validate(); err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func EncodeBrowserAuthenticatedSession(value BrowserAuthenticatedSession) ([]byte, error) {
|
||||||
|
if err := value.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return json.Marshal(value)
|
||||||
|
}
|
||||||
|
|
||||||
func (v CapabilityProfile) Validate() error {
|
func (v CapabilityProfile) Validate() error {
|
||||||
var violations []FieldViolation
|
var violations []FieldViolation
|
||||||
if v.Transport == "" {
|
if v.Transport == "" {
|
||||||
@@ -1080,6 +1243,122 @@ func EncodeChannelFrame(value ChannelFrame) ([]byte, error) {
|
|||||||
return json.Marshal(value)
|
return json.Marshal(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (v ClientSessionAuthority) Validate() error {
|
||||||
|
var violations []FieldViolation
|
||||||
|
if v.Version == "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "version", Code: "required"})
|
||||||
|
}
|
||||||
|
if v.Version != "1" && v.Version != "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "version", Code: "invalid_value"})
|
||||||
|
}
|
||||||
|
if v.SessionID == "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "session_id", Code: "required"})
|
||||||
|
}
|
||||||
|
if len(v.SessionID) < 1 && v.SessionID != "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "session_id", Code: "min_length"})
|
||||||
|
}
|
||||||
|
if len(v.SessionID) > 128 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "session_id", Code: "max_length"})
|
||||||
|
}
|
||||||
|
if v.GatewayID == "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "gateway_id", Code: "required"})
|
||||||
|
}
|
||||||
|
if len(v.GatewayID) < 1 && v.GatewayID != "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "gateway_id", Code: "min_length"})
|
||||||
|
}
|
||||||
|
if len(v.GatewayID) > 128 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "gateway_id", Code: "max_length"})
|
||||||
|
}
|
||||||
|
if v.Audience == "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "audience", Code: "required"})
|
||||||
|
}
|
||||||
|
if len(v.Audience) < 1 && v.Audience != "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "audience", Code: "min_length"})
|
||||||
|
}
|
||||||
|
if len(v.Audience) > 256 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "audience", Code: "max_length"})
|
||||||
|
}
|
||||||
|
if v.ReconnectSequence != 0 && v.ReconnectSequence < 0 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "reconnect_sequence", Code: "minimum"})
|
||||||
|
}
|
||||||
|
if v.ExpiresAt == "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "expires_at", Code: "required"})
|
||||||
|
}
|
||||||
|
if len(v.ExpiresAt) > 64 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "expires_at", Code: "max_length"})
|
||||||
|
}
|
||||||
|
if v.ExpiresAt != "" {
|
||||||
|
if parsed, err := time.Parse(time.RFC3339Nano, v.ExpiresAt); err != nil || parsed.UTC().Format(time.RFC3339Nano) != v.ExpiresAt {
|
||||||
|
violations = append(violations, FieldViolation{Field: "expires_at", Code: "invalid_time"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if reflect.DeepEqual(v.Capabilities, CapabilityProfile{}) {
|
||||||
|
violations = append(violations, FieldViolation{Field: "capabilities", Code: "required"})
|
||||||
|
}
|
||||||
|
if err := v.Capabilities.Validate(); err != nil {
|
||||||
|
violations = append(violations, FieldViolation{Field: "capabilities", Code: "invalid_object"})
|
||||||
|
}
|
||||||
|
if len(violations) > 0 {
|
||||||
|
return ValidationError{Violations: violations}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecodeClientSessionAuthority(data []byte) (ClientSessionAuthority, error) {
|
||||||
|
var value ClientSessionAuthority
|
||||||
|
if len(data) > 1024*1024 {
|
||||||
|
return value, errors.New("protocol payload exceeds limit")
|
||||||
|
}
|
||||||
|
var fields map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(data, &fields); err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
if raw, ok := fields["audience"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "audience", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["capabilities"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "capabilities", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["expires_at"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "expires_at", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["gateway_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "gateway_id", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["reconnect_sequence"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "reconnect_sequence", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["session_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "session_id", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["version"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "version", Code: "required"}}}
|
||||||
|
}
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(&value); err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
var trailing any
|
||||||
|
if err := decoder.Decode(&trailing); err != io.EOF {
|
||||||
|
if err == nil {
|
||||||
|
return value, errors.New("trailing JSON value")
|
||||||
|
}
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
if err := value.Validate(); err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func EncodeClientSessionAuthority(value ClientSessionAuthority) ([]byte, error) {
|
||||||
|
if err := value.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return json.Marshal(value)
|
||||||
|
}
|
||||||
|
|
||||||
func (v ClipboardPolicy) Validate() error {
|
func (v ClipboardPolicy) Validate() error {
|
||||||
var violations []FieldViolation
|
var violations []FieldViolation
|
||||||
if v.MaxTextBytes == 0 {
|
if v.MaxTextBytes == 0 {
|
||||||
@@ -1625,6 +1904,84 @@ func EncodeDeviceRegistrationRequest(value DeviceRegistrationRequest) ([]byte, e
|
|||||||
return json.Marshal(value)
|
return json.Marshal(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (v DisplayMode) Validate() error {
|
||||||
|
var violations []FieldViolation
|
||||||
|
if v.ResolutionWidth == 0 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "resolution_width", Code: "required"})
|
||||||
|
}
|
||||||
|
if v.ResolutionWidth != 0 && v.ResolutionWidth < 320 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "resolution_width", Code: "minimum"})
|
||||||
|
}
|
||||||
|
if v.ResolutionWidth > 16384 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "resolution_width", Code: "maximum"})
|
||||||
|
}
|
||||||
|
if v.ResolutionHeight == 0 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "resolution_height", Code: "required"})
|
||||||
|
}
|
||||||
|
if v.ResolutionHeight != 0 && v.ResolutionHeight < 200 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "resolution_height", Code: "minimum"})
|
||||||
|
}
|
||||||
|
if v.ResolutionHeight > 8640 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "resolution_height", Code: "maximum"})
|
||||||
|
}
|
||||||
|
if v.Fps == 0 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "fps", Code: "required"})
|
||||||
|
}
|
||||||
|
if v.Fps != 0 && v.Fps < 1 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "fps", Code: "minimum"})
|
||||||
|
}
|
||||||
|
if v.Fps > 240 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "fps", Code: "maximum"})
|
||||||
|
}
|
||||||
|
if len(violations) > 0 {
|
||||||
|
return ValidationError{Violations: violations}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecodeDisplayMode(data []byte) (DisplayMode, error) {
|
||||||
|
var value DisplayMode
|
||||||
|
if len(data) > 1024*1024 {
|
||||||
|
return value, errors.New("protocol payload exceeds limit")
|
||||||
|
}
|
||||||
|
var fields map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(data, &fields); err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
if raw, ok := fields["fps"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "fps", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["resolution_height"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "resolution_height", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["resolution_width"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "resolution_width", Code: "required"}}}
|
||||||
|
}
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(&value); err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
var trailing any
|
||||||
|
if err := decoder.Decode(&trailing); err != io.EOF {
|
||||||
|
if err == nil {
|
||||||
|
return value, errors.New("trailing JSON value")
|
||||||
|
}
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
if err := value.Validate(); err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func EncodeDisplayMode(value DisplayMode) ([]byte, error) {
|
||||||
|
if err := value.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return json.Marshal(value)
|
||||||
|
}
|
||||||
|
|
||||||
func (v EntitledPool) Validate() error {
|
func (v EntitledPool) Validate() error {
|
||||||
var violations []FieldViolation
|
var violations []FieldViolation
|
||||||
if v.PoolID == "" {
|
if v.PoolID == "" {
|
||||||
@@ -2585,6 +2942,16 @@ func (v GatewayRegistration) Validate() error {
|
|||||||
if len(v.Features) > 64 {
|
if len(v.Features) > 64 {
|
||||||
violations = append(violations, FieldViolation{Field: "features", Code: "max_items"})
|
violations = append(violations, FieldViolation{Field: "features", Code: "max_items"})
|
||||||
}
|
}
|
||||||
|
for _, item := range v.Features {
|
||||||
|
if len(item) < 1 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "features", Code: "min_item_length"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, item := range v.Features {
|
||||||
|
if len(item) > 64 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "features", Code: "max_item_length"})
|
||||||
|
}
|
||||||
|
}
|
||||||
if reflect.DeepEqual(v.Capabilities, CapabilityProfile{}) {
|
if reflect.DeepEqual(v.Capabilities, CapabilityProfile{}) {
|
||||||
violations = append(violations, FieldViolation{Field: "capabilities", Code: "required"})
|
violations = append(violations, FieldViolation{Field: "capabilities", Code: "required"})
|
||||||
}
|
}
|
||||||
@@ -3127,6 +3494,16 @@ func (v ManifestGateway) Validate() error {
|
|||||||
if len(v.Addresses) > 4 {
|
if len(v.Addresses) > 4 {
|
||||||
violations = append(violations, FieldViolation{Field: "addresses", Code: "max_items"})
|
violations = append(violations, FieldViolation{Field: "addresses", Code: "max_items"})
|
||||||
}
|
}
|
||||||
|
for _, item := range v.Addresses {
|
||||||
|
if len(item) < 1 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "addresses", Code: "min_item_length"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, item := range v.Addresses {
|
||||||
|
if len(item) > 256 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "addresses", Code: "max_item_length"})
|
||||||
|
}
|
||||||
|
}
|
||||||
if v.PublicIdentity == "" {
|
if v.PublicIdentity == "" {
|
||||||
violations = append(violations, FieldViolation{Field: "public_identity", Code: "required"})
|
violations = append(violations, FieldViolation{Field: "public_identity", Code: "required"})
|
||||||
}
|
}
|
||||||
@@ -3202,6 +3579,11 @@ func (v ManifestProfile) Validate() error {
|
|||||||
if err := v.Bounds.Validate(); err != nil {
|
if err := v.Bounds.Validate(); err != nil {
|
||||||
violations = append(violations, FieldViolation{Field: "bounds", Code: "invalid_object"})
|
violations = append(violations, FieldViolation{Field: "bounds", Code: "invalid_object"})
|
||||||
}
|
}
|
||||||
|
if v.DisplayMode != nil {
|
||||||
|
if err := v.DisplayMode.Validate(); err != nil {
|
||||||
|
violations = append(violations, FieldViolation{Field: "display_mode", Code: "invalid_object"})
|
||||||
|
}
|
||||||
|
}
|
||||||
if len(violations) > 0 {
|
if len(violations) > 0 {
|
||||||
return ValidationError{Violations: violations}
|
return ValidationError{Violations: violations}
|
||||||
}
|
}
|
||||||
@@ -3223,6 +3605,9 @@ func DecodeManifestProfile(data []byte) (ManifestProfile, error) {
|
|||||||
if raw, ok := fields["id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
if raw, ok := fields["id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
return value, ValidationError{Violations: []FieldViolation{{Field: "id", Code: "required"}}}
|
return value, ValidationError{Violations: []FieldViolation{{Field: "id", Code: "required"}}}
|
||||||
}
|
}
|
||||||
|
if raw, ok := fields["display_mode"]; ok && bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "display_mode", Code: "invalid_object"}}}
|
||||||
|
}
|
||||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||||
decoder.DisallowUnknownFields()
|
decoder.DisallowUnknownFields()
|
||||||
if err := decoder.Decode(&value); err != nil {
|
if err := decoder.Decode(&value); err != nil {
|
||||||
@@ -3259,12 +3644,32 @@ func (v ManifestTunnel) Validate() error {
|
|||||||
if len(v.Versions) > 4 {
|
if len(v.Versions) > 4 {
|
||||||
violations = append(violations, FieldViolation{Field: "versions", Code: "max_items"})
|
violations = append(violations, FieldViolation{Field: "versions", Code: "max_items"})
|
||||||
}
|
}
|
||||||
|
for _, item := range v.Versions {
|
||||||
|
if len(item) < 1 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "versions", Code: "min_item_length"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, item := range v.Versions {
|
||||||
|
if len(item) > 64 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "versions", Code: "max_item_length"})
|
||||||
|
}
|
||||||
|
}
|
||||||
if v.Features == nil {
|
if v.Features == nil {
|
||||||
violations = append(violations, FieldViolation{Field: "features", Code: "required"})
|
violations = append(violations, FieldViolation{Field: "features", Code: "required"})
|
||||||
}
|
}
|
||||||
if len(v.Features) > 32 {
|
if len(v.Features) > 32 {
|
||||||
violations = append(violations, FieldViolation{Field: "features", Code: "max_items"})
|
violations = append(violations, FieldViolation{Field: "features", Code: "max_items"})
|
||||||
}
|
}
|
||||||
|
for _, item := range v.Features {
|
||||||
|
if len(item) < 1 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "features", Code: "min_item_length"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, item := range v.Features {
|
||||||
|
if len(item) > 64 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "features", Code: "max_item_length"})
|
||||||
|
}
|
||||||
|
}
|
||||||
if len(violations) > 0 {
|
if len(violations) > 0 {
|
||||||
return ValidationError{Violations: violations}
|
return ValidationError{Violations: violations}
|
||||||
}
|
}
|
||||||
@@ -3311,6 +3716,114 @@ func EncodeManifestTunnel(value ManifestTunnel) ([]byte, error) {
|
|||||||
return json.Marshal(value)
|
return json.Marshal(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (v NativeAuthenticatedSession) Validate() error {
|
||||||
|
var violations []FieldViolation
|
||||||
|
if v.Username == "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "username", Code: "required"})
|
||||||
|
}
|
||||||
|
if len(v.Username) < 1 && v.Username != "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "username", Code: "min_length"})
|
||||||
|
}
|
||||||
|
if len(v.Username) > 256 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "username", Code: "max_length"})
|
||||||
|
}
|
||||||
|
if v.Provider == "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "provider", Code: "required"})
|
||||||
|
}
|
||||||
|
if len(v.Provider) < 1 && v.Provider != "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "provider", Code: "min_length"})
|
||||||
|
}
|
||||||
|
if len(v.Provider) > 64 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "provider", Code: "max_length"})
|
||||||
|
}
|
||||||
|
if v.Roles == nil {
|
||||||
|
violations = append(violations, FieldViolation{Field: "roles", Code: "required"})
|
||||||
|
}
|
||||||
|
if len(v.Roles) > 16 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "roles", Code: "max_items"})
|
||||||
|
}
|
||||||
|
for _, item := range v.Roles {
|
||||||
|
if len(item) < 1 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "roles", Code: "min_item_length"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, item := range v.Roles {
|
||||||
|
if len(item) > 64 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "roles", Code: "max_item_length"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, item := range v.Roles {
|
||||||
|
if len(item) > 64 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "roles", Code: "max_item_bytes"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v.Role == "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "role", Code: "required"})
|
||||||
|
}
|
||||||
|
if v.Role != "" && !(v.Role == "user" || v.Role == "admin") {
|
||||||
|
violations = append(violations, FieldViolation{Field: "role", Code: "invalid_value"})
|
||||||
|
}
|
||||||
|
if reflect.DeepEqual(v.NativeIdentity, NativeSessionIdentity{}) {
|
||||||
|
violations = append(violations, FieldViolation{Field: "native_identity", Code: "required"})
|
||||||
|
}
|
||||||
|
if err := v.NativeIdentity.Validate(); err != nil {
|
||||||
|
violations = append(violations, FieldViolation{Field: "native_identity", Code: "invalid_object"})
|
||||||
|
}
|
||||||
|
if len(violations) > 0 {
|
||||||
|
return ValidationError{Violations: violations}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecodeNativeAuthenticatedSession(data []byte) (NativeAuthenticatedSession, error) {
|
||||||
|
var value NativeAuthenticatedSession
|
||||||
|
if len(data) > 1024*1024 {
|
||||||
|
return value, errors.New("protocol payload exceeds limit")
|
||||||
|
}
|
||||||
|
var fields map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(data, &fields); err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
if raw, ok := fields["native_identity"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "native_identity", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["provider"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "provider", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["role"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "role", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["roles"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "roles", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["username"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "username", Code: "required"}}}
|
||||||
|
}
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(&value); err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
var trailing any
|
||||||
|
if err := decoder.Decode(&trailing); err != io.EOF {
|
||||||
|
if err == nil {
|
||||||
|
return value, errors.New("trailing JSON value")
|
||||||
|
}
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
if err := value.Validate(); err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func EncodeNativeAuthenticatedSession(value NativeAuthenticatedSession) ([]byte, error) {
|
||||||
|
if err := value.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return json.Marshal(value)
|
||||||
|
}
|
||||||
|
|
||||||
func (v NativeCredential) Validate() error {
|
func (v NativeCredential) Validate() error {
|
||||||
var violations []FieldViolation
|
var violations []FieldViolation
|
||||||
if len(v.DeviceID) > 128 {
|
if len(v.DeviceID) > 128 {
|
||||||
@@ -3414,6 +3927,176 @@ func EncodeNativeCredential(value NativeCredential) ([]byte, error) {
|
|||||||
return json.Marshal(value)
|
return json.Marshal(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (v NativeSessionIdentity) Validate() error {
|
||||||
|
var violations []FieldViolation
|
||||||
|
if v.ClientDeviceID == "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "client_device_id", Code: "required"})
|
||||||
|
}
|
||||||
|
if len(v.ClientDeviceID) < 1 && v.ClientDeviceID != "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "client_device_id", Code: "min_length"})
|
||||||
|
}
|
||||||
|
if len(v.ClientDeviceID) > 128 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "client_device_id", Code: "max_length"})
|
||||||
|
}
|
||||||
|
if v.DeviceKeyID == "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "device_key_id", Code: "required"})
|
||||||
|
}
|
||||||
|
if len(v.DeviceKeyID) < 1 && v.DeviceKeyID != "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "device_key_id", Code: "min_length"})
|
||||||
|
}
|
||||||
|
if len(v.DeviceKeyID) > 128 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "device_key_id", Code: "max_length"})
|
||||||
|
}
|
||||||
|
if len(violations) > 0 {
|
||||||
|
return ValidationError{Violations: violations}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecodeNativeSessionIdentity(data []byte) (NativeSessionIdentity, error) {
|
||||||
|
var value NativeSessionIdentity
|
||||||
|
if len(data) > 1024*1024 {
|
||||||
|
return value, errors.New("protocol payload exceeds limit")
|
||||||
|
}
|
||||||
|
var fields map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(data, &fields); err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
if raw, ok := fields["client_device_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "client_device_id", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["device_key_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "device_key_id", Code: "required"}}}
|
||||||
|
}
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(&value); err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
var trailing any
|
||||||
|
if err := decoder.Decode(&trailing); err != io.EOF {
|
||||||
|
if err == nil {
|
||||||
|
return value, errors.New("trailing JSON value")
|
||||||
|
}
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
if err := value.Validate(); err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func EncodeNativeSessionIdentity(value NativeSessionIdentity) ([]byte, error) {
|
||||||
|
if err := value.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return json.Marshal(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v NativeTunnelCredential) Validate() error {
|
||||||
|
var violations []FieldViolation
|
||||||
|
if v.ClientDeviceID == "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "client_device_id", Code: "required"})
|
||||||
|
}
|
||||||
|
if len(v.ClientDeviceID) < 1 && v.ClientDeviceID != "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "client_device_id", Code: "min_length"})
|
||||||
|
}
|
||||||
|
if len(v.ClientDeviceID) > 128 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "client_device_id", Code: "max_length"})
|
||||||
|
}
|
||||||
|
if v.DeviceKeyID == "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "device_key_id", Code: "required"})
|
||||||
|
}
|
||||||
|
if len(v.DeviceKeyID) < 1 && v.DeviceKeyID != "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "device_key_id", Code: "min_length"})
|
||||||
|
}
|
||||||
|
if len(v.DeviceKeyID) > 128 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "device_key_id", Code: "max_length"})
|
||||||
|
}
|
||||||
|
if v.CertificateChainPem == "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "certificate_chain_pem", Code: "required"})
|
||||||
|
}
|
||||||
|
if len(v.CertificateChainPem) < 1 && v.CertificateChainPem != "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "certificate_chain_pem", Code: "min_length"})
|
||||||
|
}
|
||||||
|
if len(v.CertificateChainPem) > 65536 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "certificate_chain_pem", Code: "max_length"})
|
||||||
|
}
|
||||||
|
if v.TrustBundlePem == "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "trust_bundle_pem", Code: "required"})
|
||||||
|
}
|
||||||
|
if len(v.TrustBundlePem) < 1 && v.TrustBundlePem != "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "trust_bundle_pem", Code: "min_length"})
|
||||||
|
}
|
||||||
|
if len(v.TrustBundlePem) > 65536 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "trust_bundle_pem", Code: "max_length"})
|
||||||
|
}
|
||||||
|
if v.ExpiresAt == "" {
|
||||||
|
violations = append(violations, FieldViolation{Field: "expires_at", Code: "required"})
|
||||||
|
}
|
||||||
|
if len(v.ExpiresAt) > 64 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "expires_at", Code: "max_length"})
|
||||||
|
}
|
||||||
|
if v.ExpiresAt != "" {
|
||||||
|
if parsed, err := time.Parse(time.RFC3339Nano, v.ExpiresAt); err != nil || parsed.UTC().Format(time.RFC3339Nano) != v.ExpiresAt {
|
||||||
|
violations = append(violations, FieldViolation{Field: "expires_at", Code: "invalid_time"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(violations) > 0 {
|
||||||
|
return ValidationError{Violations: violations}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecodeNativeTunnelCredential(data []byte) (NativeTunnelCredential, error) {
|
||||||
|
var value NativeTunnelCredential
|
||||||
|
if len(data) > 1024*1024 {
|
||||||
|
return value, errors.New("protocol payload exceeds limit")
|
||||||
|
}
|
||||||
|
var fields map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(data, &fields); err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
if raw, ok := fields["certificate_chain_pem"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "certificate_chain_pem", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["client_device_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "client_device_id", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["device_key_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "device_key_id", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["expires_at"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "expires_at", Code: "required"}}}
|
||||||
|
}
|
||||||
|
if raw, ok := fields["trust_bundle_pem"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "trust_bundle_pem", Code: "required"}}}
|
||||||
|
}
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(&value); err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
var trailing any
|
||||||
|
if err := decoder.Decode(&trailing); err != io.EOF {
|
||||||
|
if err == nil {
|
||||||
|
return value, errors.New("trailing JSON value")
|
||||||
|
}
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
if err := value.Validate(); err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func EncodeNativeTunnelCredential(value NativeTunnelCredential) ([]byte, error) {
|
||||||
|
if err := value.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return json.Marshal(value)
|
||||||
|
}
|
||||||
|
|
||||||
func (v PageInfo) Validate() error {
|
func (v PageInfo) Validate() error {
|
||||||
var violations []FieldViolation
|
var violations []FieldViolation
|
||||||
if v.Limit == 0 {
|
if v.Limit == 0 {
|
||||||
@@ -3763,6 +4446,16 @@ func (v ProviderState) Validate() error {
|
|||||||
if len(v.Channels) > 8 {
|
if len(v.Channels) > 8 {
|
||||||
violations = append(violations, FieldViolation{Field: "channels", Code: "max_items"})
|
violations = append(violations, FieldViolation{Field: "channels", Code: "max_items"})
|
||||||
}
|
}
|
||||||
|
for _, item := range v.Channels {
|
||||||
|
if len(item) < 1 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "channels", Code: "min_item_length"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, item := range v.Channels {
|
||||||
|
if len(item) > 64 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "channels", Code: "max_item_length"})
|
||||||
|
}
|
||||||
|
}
|
||||||
if len(violations) > 0 {
|
if len(violations) > 0 {
|
||||||
return ValidationError{Violations: violations}
|
return ValidationError{Violations: violations}
|
||||||
}
|
}
|
||||||
@@ -4649,11 +5342,10 @@ func (v SessionRequest) Validate() error {
|
|||||||
if len(v.IdempotencyKey) > 256 {
|
if len(v.IdempotencyKey) > 256 {
|
||||||
violations = append(violations, FieldViolation{Field: "idempotency_key", Code: "max_length"})
|
violations = append(violations, FieldViolation{Field: "idempotency_key", Code: "max_length"})
|
||||||
}
|
}
|
||||||
if reflect.DeepEqual(v.PolicySnapshot, AllocationPolicy{}) {
|
if v.RequestedDisplayMode != nil {
|
||||||
violations = append(violations, FieldViolation{Field: "policy_snapshot", Code: "required"})
|
if err := v.RequestedDisplayMode.Validate(); err != nil {
|
||||||
}
|
violations = append(violations, FieldViolation{Field: "requested_display_mode", Code: "invalid_object"})
|
||||||
if err := v.PolicySnapshot.Validate(); err != nil {
|
}
|
||||||
violations = append(violations, FieldViolation{Field: "policy_snapshot", Code: "invalid_object"})
|
|
||||||
}
|
}
|
||||||
if len(violations) > 0 {
|
if len(violations) > 0 {
|
||||||
return ValidationError{Violations: violations}
|
return ValidationError{Violations: violations}
|
||||||
@@ -4679,12 +5371,12 @@ func DecodeSessionRequest(data []byte) (SessionRequest, error) {
|
|||||||
if raw, ok := fields["idempotency_key"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
if raw, ok := fields["idempotency_key"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
return value, ValidationError{Violations: []FieldViolation{{Field: "idempotency_key", Code: "required"}}}
|
return value, ValidationError{Violations: []FieldViolation{{Field: "idempotency_key", Code: "required"}}}
|
||||||
}
|
}
|
||||||
if raw, ok := fields["policy_snapshot"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
|
||||||
return value, ValidationError{Violations: []FieldViolation{{Field: "policy_snapshot", Code: "required"}}}
|
|
||||||
}
|
|
||||||
if raw, ok := fields["pool_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
if raw, ok := fields["pool_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
return value, ValidationError{Violations: []FieldViolation{{Field: "pool_id", Code: "required"}}}
|
return value, ValidationError{Violations: []FieldViolation{{Field: "pool_id", Code: "required"}}}
|
||||||
}
|
}
|
||||||
|
if raw, ok := fields["requested_display_mode"]; ok && bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||||
|
return value, ValidationError{Violations: []FieldViolation{{Field: "requested_display_mode", Code: "invalid_object"}}}
|
||||||
|
}
|
||||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||||
decoder.DisallowUnknownFields()
|
decoder.DisallowUnknownFields()
|
||||||
if err := decoder.Decode(&value); err != nil {
|
if err := decoder.Decode(&value); err != nil {
|
||||||
@@ -4937,12 +5629,22 @@ func (v VersionNegotiation) Validate() error {
|
|||||||
if len(v.SupportedVersions) > 3 {
|
if len(v.SupportedVersions) > 3 {
|
||||||
violations = append(violations, FieldViolation{Field: "supported_versions", Code: "max_items"})
|
violations = append(violations, FieldViolation{Field: "supported_versions", Code: "max_items"})
|
||||||
}
|
}
|
||||||
|
for _, item := range v.SupportedVersions {
|
||||||
|
if len(item) > 16 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "supported_versions", Code: "max_item_length"})
|
||||||
|
}
|
||||||
|
}
|
||||||
if v.Features == nil {
|
if v.Features == nil {
|
||||||
violations = append(violations, FieldViolation{Field: "features", Code: "required"})
|
violations = append(violations, FieldViolation{Field: "features", Code: "required"})
|
||||||
}
|
}
|
||||||
if len(v.Features) > 64 {
|
if len(v.Features) > 64 {
|
||||||
violations = append(violations, FieldViolation{Field: "features", Code: "max_items"})
|
violations = append(violations, FieldViolation{Field: "features", Code: "max_items"})
|
||||||
}
|
}
|
||||||
|
for _, item := range v.Features {
|
||||||
|
if len(item) > 64 {
|
||||||
|
violations = append(violations, FieldViolation{Field: "features", Code: "max_item_length"})
|
||||||
|
}
|
||||||
|
}
|
||||||
if len(violations) > 0 {
|
if len(violations) > 0 {
|
||||||
return ValidationError{Violations: violations}
|
return ValidationError{Violations: violations}
|
||||||
}
|
}
|
||||||
@@ -4989,6 +5691,30 @@ func EncodeVersionNegotiation(value VersionNegotiation) ([]byte, error) {
|
|||||||
return json.Marshal(value)
|
return json.Marshal(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func DeviceRegistrationProofTranscript(serverID, principalID, deviceID, challenge []byte, expiryUnixMilliseconds int64) ([]byte, error) {
|
||||||
|
for _, value := range []struct {
|
||||||
|
field string
|
||||||
|
bytes []byte
|
||||||
|
length int
|
||||||
|
}{{"server_id", serverID, 16}, {"principal_id", principalID, 16}, {"device_id", deviceID, 16}, {"challenge", challenge, 32}} {
|
||||||
|
if len(value.bytes) != value.length {
|
||||||
|
return nil, ValidationError{Violations: []FieldViolation{{Field: value.field, Code: "invalid_length"}}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if expiryUnixMilliseconds < 0 {
|
||||||
|
return nil, ValidationError{Violations: []FieldViolation{{Field: "expiry_unix_milliseconds", Code: "minimum"}}}
|
||||||
|
}
|
||||||
|
transcript := make([]byte, 0, 112)
|
||||||
|
transcript = append(transcript, "versevdi-device-proof-v1"...)
|
||||||
|
transcript = append(transcript, serverID...)
|
||||||
|
transcript = append(transcript, principalID...)
|
||||||
|
transcript = append(transcript, deviceID...)
|
||||||
|
transcript = append(transcript, challenge...)
|
||||||
|
var expiry [8]byte
|
||||||
|
binary.BigEndian.PutUint64(expiry[:], uint64(expiryUnixMilliseconds))
|
||||||
|
return append(transcript, expiry[:]...), nil
|
||||||
|
}
|
||||||
|
|
||||||
var ErrNoCapabilityOverlap = errors.New("no capability overlap")
|
var ErrNoCapabilityOverlap = errors.New("no capability overlap")
|
||||||
|
|
||||||
func IntersectCapabilityProfiles(profiles ...CapabilityProfile) (CapabilityProfile, error) {
|
func IntersectCapabilityProfiles(profiles ...CapabilityProfile) (CapabilityProfile, error) {
|
||||||
|
|||||||
+7
-7
@@ -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": "00fdba050eb924a54dd3d63aac0a38560341b675f0de4e3e9631ee895057a9b6",
|
"generator_sha256": "00c1905fc611ca9e226cd90da761b48b8e203734b10542befea397a30082d360",
|
||||||
"protocol_version": "1.0.0",
|
"protocol_version": "1.0.0",
|
||||||
"schema_sha256": "a86cbdaf2cfb884e3d98467968007e731ca55c6f6eb6dbd5cd6b95e062a9b058"
|
"schema_sha256": "b2353c12269304289b4e872f27cc370ae61b958dea90d9fb7b6ab8afd7d37248"
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
+288
-13
@@ -1,9 +1,9 @@
|
|||||||
// Code generated by tools/generate.py; DO NOT EDIT.
|
// Code generated by tools/generate.py; DO NOT EDIT.
|
||||||
#![allow(non_snake_case)]
|
#![allow(non_snake_case)]
|
||||||
pub const SCHEMA_SHA256: &str = "a86cbdaf2cfb884e3d98467968007e731ca55c6f6eb6dbd5cd6b95e062a9b058";
|
pub const SCHEMA_SHA256: &str = "b2353c12269304289b4e872f27cc370ae61b958dea90d9fb7b6ab8afd7d37248";
|
||||||
pub const CURRENT_WIRE_VERSION: &str = "1";
|
pub const CURRENT_WIRE_VERSION: &str = "2";
|
||||||
pub const N_MINUS_1_WIRE_VERSION: &str = "0";
|
pub const N_MINUS_1_WIRE_VERSION: &str = "1";
|
||||||
pub const N_MINUS_2_WIRE_VERSION: &str = "-1";
|
pub const N_MINUS_2_WIRE_VERSION: &str = "0";
|
||||||
pub type JsonObject = std::collections::BTreeMap<String, String>;
|
pub type JsonObject = std::collections::BTreeMap<String, String>;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -30,6 +30,19 @@ fn valid_base64_url(value: &str) -> bool {
|
|||||||
_ => false,
|
_ => 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'
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct AllocationPolicy {
|
pub struct AllocationPolicy {
|
||||||
@@ -136,11 +149,13 @@ pub struct BrokerSession {
|
|||||||
requestedAt: String,
|
requestedAt: String,
|
||||||
endedAt: Option<String>,
|
endedAt: Option<String>,
|
||||||
version: i64,
|
version: i64,
|
||||||
|
requestedDisplayMode: Option<DisplayMode>,
|
||||||
|
effectiveDisplayMode: Option<DisplayMode>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BrokerSession {
|
impl BrokerSession {
|
||||||
pub fn new(id: String, principalId: String, poolId: String, assignmentId: Option<String>, state: String, policySnapshot: AllocationPolicy, reconnectDeadline: Option<String>, outcome: Option<String>, failureCode: Option<String>, cleanupState: String, idempotencyKey: String, correlationId: String, requestedAt: String, endedAt: Option<String>, version: i64) -> Result<Self, ValidationError> {
|
pub fn new(id: String, principalId: String, poolId: String, assignmentId: Option<String>, state: String, policySnapshot: AllocationPolicy, reconnectDeadline: Option<String>, outcome: Option<String>, failureCode: Option<String>, cleanupState: String, idempotencyKey: String, correlationId: String, requestedAt: String, endedAt: Option<String>, version: i64, requestedDisplayMode: Option<DisplayMode>, effectiveDisplayMode: Option<DisplayMode>) -> Result<Self, ValidationError> {
|
||||||
let value = Self { id, principalId, poolId, assignmentId, state, policySnapshot, reconnectDeadline, outcome, failureCode, cleanupState, idempotencyKey, correlationId, requestedAt, endedAt, version };
|
let value = Self { id, principalId, poolId, assignmentId, state, policySnapshot, reconnectDeadline, outcome, failureCode, cleanupState, idempotencyKey, correlationId, requestedAt, endedAt, version, requestedDisplayMode, effectiveDisplayMode };
|
||||||
value.validate()?;
|
value.validate()?;
|
||||||
Ok(value)
|
Ok(value)
|
||||||
}
|
}
|
||||||
@@ -163,6 +178,7 @@ impl BrokerSession {
|
|||||||
self.policySnapshot.validate().map_err(|_| ValidationError::new("policy_snapshot", "invalid_object"))?;
|
self.policySnapshot.validate().map_err(|_| ValidationError::new("policy_snapshot", "invalid_object"))?;
|
||||||
if let Some(value) = &self.reconnectDeadline {
|
if let Some(value) = &self.reconnectDeadline {
|
||||||
if value.len() > 64 { return Err(ValidationError::new("reconnect_deadline", "max_length")); }
|
if value.len() > 64 { return Err(ValidationError::new("reconnect_deadline", "max_length")); }
|
||||||
|
if !valid_rfc3339_utc(value.as_str()) { return Err(ValidationError::new("reconnect_deadline", "invalid_time")); }
|
||||||
}
|
}
|
||||||
if let Some(value) = &self.outcome {
|
if let Some(value) = &self.outcome {
|
||||||
if value.len() > 64 { return Err(ValidationError::new("outcome", "max_length")); }
|
if value.len() > 64 { return Err(ValidationError::new("outcome", "max_length")); }
|
||||||
@@ -180,10 +196,18 @@ impl BrokerSession {
|
|||||||
if !self.correlationId.is_empty() && self.correlationId.len() < 1 { return Err(ValidationError::new("correlation_id", "min_length")); }
|
if !self.correlationId.is_empty() && self.correlationId.len() < 1 { return Err(ValidationError::new("correlation_id", "min_length")); }
|
||||||
if self.correlationId.len() > 128 { return Err(ValidationError::new("correlation_id", "max_length")); }
|
if self.correlationId.len() > 128 { return Err(ValidationError::new("correlation_id", "max_length")); }
|
||||||
if self.requestedAt.len() > 64 { return Err(ValidationError::new("requested_at", "max_length")); }
|
if self.requestedAt.len() > 64 { return Err(ValidationError::new("requested_at", "max_length")); }
|
||||||
|
if !valid_rfc3339_utc(self.requestedAt.as_str()) { return Err(ValidationError::new("requested_at", "invalid_time")); }
|
||||||
if let Some(value) = &self.endedAt {
|
if let Some(value) = &self.endedAt {
|
||||||
if value.len() > 64 { return Err(ValidationError::new("ended_at", "max_length")); }
|
if value.len() > 64 { return Err(ValidationError::new("ended_at", "max_length")); }
|
||||||
|
if !valid_rfc3339_utc(value.as_str()) { return Err(ValidationError::new("ended_at", "invalid_time")); }
|
||||||
}
|
}
|
||||||
if self.version < 1 { return Err(ValidationError::new("version", "minimum")); }
|
if self.version < 1 { return Err(ValidationError::new("version", "minimum")); }
|
||||||
|
if let Some(value) = &self.requestedDisplayMode {
|
||||||
|
value.validate().map_err(|_| ValidationError::new("requested_display_mode", "invalid_object"))?;
|
||||||
|
}
|
||||||
|
if let Some(value) = &self.effectiveDisplayMode {
|
||||||
|
value.validate().map_err(|_| ValidationError::new("effective_display_mode", "invalid_object"))?;
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
pub fn id(&self) -> &String { &self.id }
|
pub fn id(&self) -> &String { &self.id }
|
||||||
@@ -201,6 +225,42 @@ impl BrokerSession {
|
|||||||
pub fn requestedAt(&self) -> &String { &self.requestedAt }
|
pub fn requestedAt(&self) -> &String { &self.requestedAt }
|
||||||
pub fn endedAt(&self) -> &Option<String> { &self.endedAt }
|
pub fn endedAt(&self) -> &Option<String> { &self.endedAt }
|
||||||
pub fn version(&self) -> &i64 { &self.version }
|
pub fn version(&self) -> &i64 { &self.version }
|
||||||
|
pub fn requestedDisplayMode(&self) -> &Option<DisplayMode> { &self.requestedDisplayMode }
|
||||||
|
pub fn effectiveDisplayMode(&self) -> &Option<DisplayMode> { &self.effectiveDisplayMode }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct BrowserAuthenticatedSession {
|
||||||
|
username: String,
|
||||||
|
provider: String,
|
||||||
|
roles: Vec<String>,
|
||||||
|
role: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BrowserAuthenticatedSession {
|
||||||
|
pub fn new(username: String, provider: String, roles: Vec<String>, role: String) -> Result<Self, ValidationError> {
|
||||||
|
let value = Self { username, provider, roles, role };
|
||||||
|
value.validate()?;
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
pub fn validate(&self) -> Result<(), ValidationError> {
|
||||||
|
if self.username.is_empty() { return Err(ValidationError::new("username", "required")); }
|
||||||
|
if !self.username.is_empty() && self.username.len() < 1 { return Err(ValidationError::new("username", "min_length")); }
|
||||||
|
if self.username.len() > 256 { return Err(ValidationError::new("username", "max_length")); }
|
||||||
|
if self.provider.is_empty() { return Err(ValidationError::new("provider", "required")); }
|
||||||
|
if !self.provider.is_empty() && self.provider.len() < 1 { return Err(ValidationError::new("provider", "min_length")); }
|
||||||
|
if self.provider.len() > 64 { return Err(ValidationError::new("provider", "max_length")); }
|
||||||
|
if self.roles.len() > 16 { return Err(ValidationError::new("roles", "max_items")); }
|
||||||
|
for item in self.roles.iter() { if item.as_bytes().len() < 1 { return Err(ValidationError::new("roles", "min_item_length")); } }
|
||||||
|
for item in self.roles.iter() { if item.as_bytes().len() > 64 { return Err(ValidationError::new("roles", "max_item_length")); } }
|
||||||
|
for item in self.roles.iter() { if item.as_bytes().len() > 64 { return Err(ValidationError::new("roles", "max_item_bytes")); } }
|
||||||
|
if self.role != "user" && self.role != "admin" { return Err(ValidationError::new("role", "invalid_value")); }
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
pub fn username(&self) -> &String { &self.username }
|
||||||
|
pub fn provider(&self) -> &String { &self.provider }
|
||||||
|
pub fn roles(&self) -> &Vec<String> { &self.roles }
|
||||||
|
pub fn role(&self) -> &String { &self.role }
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -293,6 +353,49 @@ impl ChannelFrame {
|
|||||||
pub fn payload(&self) -> &String { &self.payload }
|
pub fn payload(&self) -> &String { &self.payload }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ClientSessionAuthority {
|
||||||
|
version: String,
|
||||||
|
sessionId: String,
|
||||||
|
gatewayId: String,
|
||||||
|
audience: String,
|
||||||
|
reconnectSequence: i64,
|
||||||
|
expiresAt: String,
|
||||||
|
capabilities: CapabilityProfile,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClientSessionAuthority {
|
||||||
|
pub fn new(version: String, sessionId: String, gatewayId: String, audience: String, reconnectSequence: i64, expiresAt: String, capabilities: CapabilityProfile) -> Result<Self, ValidationError> {
|
||||||
|
let value = Self { version, sessionId, gatewayId, audience, reconnectSequence, expiresAt, capabilities };
|
||||||
|
value.validate()?;
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
pub fn validate(&self) -> Result<(), ValidationError> {
|
||||||
|
if self.version != "1" { return Err(ValidationError::new("version", "invalid_value")); }
|
||||||
|
if self.sessionId.is_empty() { return Err(ValidationError::new("session_id", "required")); }
|
||||||
|
if !self.sessionId.is_empty() && self.sessionId.len() < 1 { return Err(ValidationError::new("session_id", "min_length")); }
|
||||||
|
if self.sessionId.len() > 128 { return Err(ValidationError::new("session_id", "max_length")); }
|
||||||
|
if self.gatewayId.is_empty() { return Err(ValidationError::new("gateway_id", "required")); }
|
||||||
|
if !self.gatewayId.is_empty() && self.gatewayId.len() < 1 { return Err(ValidationError::new("gateway_id", "min_length")); }
|
||||||
|
if self.gatewayId.len() > 128 { return Err(ValidationError::new("gateway_id", "max_length")); }
|
||||||
|
if self.audience.is_empty() { return Err(ValidationError::new("audience", "required")); }
|
||||||
|
if !self.audience.is_empty() && self.audience.len() < 1 { return Err(ValidationError::new("audience", "min_length")); }
|
||||||
|
if self.audience.len() > 256 { return Err(ValidationError::new("audience", "max_length")); }
|
||||||
|
if self.reconnectSequence < 0 { return Err(ValidationError::new("reconnect_sequence", "minimum")); }
|
||||||
|
if self.expiresAt.len() > 64 { return Err(ValidationError::new("expires_at", "max_length")); }
|
||||||
|
if !valid_rfc3339_utc(self.expiresAt.as_str()) { return Err(ValidationError::new("expires_at", "invalid_time")); }
|
||||||
|
self.capabilities.validate().map_err(|_| ValidationError::new("capabilities", "invalid_object"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
pub fn version(&self) -> &String { &self.version }
|
||||||
|
pub fn sessionId(&self) -> &String { &self.sessionId }
|
||||||
|
pub fn gatewayId(&self) -> &String { &self.gatewayId }
|
||||||
|
pub fn audience(&self) -> &String { &self.audience }
|
||||||
|
pub fn reconnectSequence(&self) -> &i64 { &self.reconnectSequence }
|
||||||
|
pub fn expiresAt(&self) -> &String { &self.expiresAt }
|
||||||
|
pub fn capabilities(&self) -> &CapabilityProfile { &self.capabilities }
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct ClipboardPolicy {
|
pub struct ClipboardPolicy {
|
||||||
clientToProviderEnabled: bool,
|
clientToProviderEnabled: bool,
|
||||||
@@ -418,6 +521,7 @@ impl DeviceChallenge {
|
|||||||
if !self.challenge.is_empty() && self.challenge.len() < 1 { return Err(ValidationError::new("challenge", "min_length")); }
|
if !self.challenge.is_empty() && self.challenge.len() < 1 { return Err(ValidationError::new("challenge", "min_length")); }
|
||||||
if self.challenge.len() > 256 { return Err(ValidationError::new("challenge", "max_length")); }
|
if self.challenge.len() > 256 { return Err(ValidationError::new("challenge", "max_length")); }
|
||||||
if self.expiresAt.len() > 64 { return Err(ValidationError::new("expires_at", "max_length")); }
|
if self.expiresAt.len() > 64 { return Err(ValidationError::new("expires_at", "max_length")); }
|
||||||
|
if !valid_rfc3339_utc(self.expiresAt.as_str()) { return Err(ValidationError::new("expires_at", "invalid_time")); }
|
||||||
if self.algorithm != "ed25519" { return Err(ValidationError::new("algorithm", "invalid_value")); }
|
if self.algorithm != "ed25519" { return Err(ValidationError::new("algorithm", "invalid_value")); }
|
||||||
if self.signatureFormat != "ed25519-domain-separated-v1" { return Err(ValidationError::new("signature_format", "invalid_value")); }
|
if self.signatureFormat != "ed25519-domain-separated-v1" { return Err(ValidationError::new("signature_format", "invalid_value")); }
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -494,6 +598,33 @@ impl DeviceRegistrationRequest {
|
|||||||
pub fn publicKey(&self) -> &String { &self.publicKey }
|
pub fn publicKey(&self) -> &String { &self.publicKey }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct DisplayMode {
|
||||||
|
resolutionWidth: i64,
|
||||||
|
resolutionHeight: i64,
|
||||||
|
fps: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DisplayMode {
|
||||||
|
pub fn new(resolutionWidth: i64, resolutionHeight: i64, fps: i64) -> Result<Self, ValidationError> {
|
||||||
|
let value = Self { resolutionWidth, resolutionHeight, fps };
|
||||||
|
value.validate()?;
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
pub fn validate(&self) -> Result<(), ValidationError> {
|
||||||
|
if self.resolutionWidth < 320 { return Err(ValidationError::new("resolution_width", "minimum")); }
|
||||||
|
if self.resolutionWidth > 16384 { return Err(ValidationError::new("resolution_width", "maximum")); }
|
||||||
|
if self.resolutionHeight < 200 { return Err(ValidationError::new("resolution_height", "minimum")); }
|
||||||
|
if self.resolutionHeight > 8640 { return Err(ValidationError::new("resolution_height", "maximum")); }
|
||||||
|
if self.fps < 1 { return Err(ValidationError::new("fps", "minimum")); }
|
||||||
|
if self.fps > 240 { return Err(ValidationError::new("fps", "maximum")); }
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
pub fn resolutionWidth(&self) -> &i64 { &self.resolutionWidth }
|
||||||
|
pub fn resolutionHeight(&self) -> &i64 { &self.resolutionHeight }
|
||||||
|
pub fn fps(&self) -> &i64 { &self.fps }
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct EntitledPool {
|
pub struct EntitledPool {
|
||||||
poolId: String,
|
poolId: String,
|
||||||
@@ -599,6 +730,7 @@ impl EventEnvelope {
|
|||||||
if self.version < 1 { return Err(ValidationError::new("version", "minimum")); }
|
if self.version < 1 { return Err(ValidationError::new("version", "minimum")); }
|
||||||
self.resource.validate().map_err(|_| ValidationError::new("resource", "invalid_object"))?;
|
self.resource.validate().map_err(|_| ValidationError::new("resource", "invalid_object"))?;
|
||||||
if self.occurredAt.len() > 64 { return Err(ValidationError::new("occurred_at", "max_length")); }
|
if self.occurredAt.len() > 64 { return Err(ValidationError::new("occurred_at", "max_length")); }
|
||||||
|
if !valid_rfc3339_utc(self.occurredAt.as_str()) { return Err(ValidationError::new("occurred_at", "invalid_time")); }
|
||||||
if self.correlationId.is_empty() { return Err(ValidationError::new("correlation_id", "required")); }
|
if self.correlationId.is_empty() { return Err(ValidationError::new("correlation_id", "required")); }
|
||||||
if !self.correlationId.is_empty() && self.correlationId.len() < 1 { return Err(ValidationError::new("correlation_id", "min_length")); }
|
if !self.correlationId.is_empty() && self.correlationId.len() < 1 { return Err(ValidationError::new("correlation_id", "min_length")); }
|
||||||
if self.correlationId.len() > 128 { return Err(ValidationError::new("correlation_id", "max_length")); }
|
if self.correlationId.len() > 128 { return Err(ValidationError::new("correlation_id", "max_length")); }
|
||||||
@@ -752,6 +884,7 @@ impl GatewayDrain {
|
|||||||
if !self.reason.is_empty() && self.reason.len() < 1 { return Err(ValidationError::new("reason", "min_length")); }
|
if !self.reason.is_empty() && self.reason.len() < 1 { return Err(ValidationError::new("reason", "min_length")); }
|
||||||
if self.reason.len() > 256 { return Err(ValidationError::new("reason", "max_length")); }
|
if self.reason.len() > 256 { return Err(ValidationError::new("reason", "max_length")); }
|
||||||
if self.deadline.len() > 64 { return Err(ValidationError::new("deadline", "max_length")); }
|
if self.deadline.len() > 64 { return Err(ValidationError::new("deadline", "max_length")); }
|
||||||
|
if !valid_rfc3339_utc(self.deadline.as_str()) { return Err(ValidationError::new("deadline", "invalid_time")); }
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
pub fn version(&self) -> &String { &self.version }
|
pub fn version(&self) -> &String { &self.version }
|
||||||
@@ -786,6 +919,7 @@ impl GatewayHeartbeat {
|
|||||||
if self.gatewayId.len() > 128 { return Err(ValidationError::new("gateway_id", "max_length")); }
|
if self.gatewayId.len() > 128 { return Err(ValidationError::new("gateway_id", "max_length")); }
|
||||||
if self.sequence < 1 { return Err(ValidationError::new("sequence", "minimum")); }
|
if self.sequence < 1 { return Err(ValidationError::new("sequence", "minimum")); }
|
||||||
if self.observedAt.len() > 64 { return Err(ValidationError::new("observed_at", "max_length")); }
|
if self.observedAt.len() > 64 { return Err(ValidationError::new("observed_at", "max_length")); }
|
||||||
|
if !valid_rfc3339_utc(self.observedAt.as_str()) { return Err(ValidationError::new("observed_at", "invalid_time")); }
|
||||||
if self.activeConnections < 0 { return Err(ValidationError::new("active_connections", "minimum")); }
|
if self.activeConnections < 0 { return Err(ValidationError::new("active_connections", "minimum")); }
|
||||||
if self.activeConnections > 1000000 { return Err(ValidationError::new("active_connections", "maximum")); }
|
if self.activeConnections > 1000000 { return Err(ValidationError::new("active_connections", "maximum")); }
|
||||||
if self.egressKbps < 0 { return Err(ValidationError::new("egress_kbps", "minimum")); }
|
if self.egressKbps < 0 { return Err(ValidationError::new("egress_kbps", "minimum")); }
|
||||||
@@ -856,6 +990,8 @@ impl GatewayRegistration {
|
|||||||
if self.bandwidthCapacityKbps < 1 { return Err(ValidationError::new("bandwidth_capacity_kbps", "minimum")); }
|
if self.bandwidthCapacityKbps < 1 { return Err(ValidationError::new("bandwidth_capacity_kbps", "minimum")); }
|
||||||
if self.bandwidthCapacityKbps > 1000000000 { return Err(ValidationError::new("bandwidth_capacity_kbps", "maximum")); }
|
if self.bandwidthCapacityKbps > 1000000000 { return Err(ValidationError::new("bandwidth_capacity_kbps", "maximum")); }
|
||||||
if self.features.len() > 64 { return Err(ValidationError::new("features", "max_items")); }
|
if self.features.len() > 64 { return Err(ValidationError::new("features", "max_items")); }
|
||||||
|
for item in self.features.iter() { if item.as_bytes().len() < 1 { return Err(ValidationError::new("features", "min_item_length")); } }
|
||||||
|
for item in self.features.iter() { if item.as_bytes().len() > 64 { return Err(ValidationError::new("features", "max_item_length")); } }
|
||||||
self.capabilities.validate().map_err(|_| ValidationError::new("capabilities", "invalid_object"))?;
|
self.capabilities.validate().map_err(|_| ValidationError::new("capabilities", "invalid_object"))?;
|
||||||
if self.protocolMinVersion > self.protocolMaxVersion { return Err(ValidationError::new("protocol_version", "invalid_order")); }
|
if self.protocolMinVersion > self.protocolMaxVersion { return Err(ValidationError::new("protocol_version", "invalid_order")); }
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -979,6 +1115,7 @@ impl GrantReference {
|
|||||||
if !self.opaqueValue.is_empty() && self.opaqueValue.len() < 43 { return Err(ValidationError::new("opaque_value", "min_length")); }
|
if !self.opaqueValue.is_empty() && self.opaqueValue.len() < 43 { return Err(ValidationError::new("opaque_value", "min_length")); }
|
||||||
if self.opaqueValue.len() > 256 { return Err(ValidationError::new("opaque_value", "max_length")); }
|
if self.opaqueValue.len() > 256 { return Err(ValidationError::new("opaque_value", "max_length")); }
|
||||||
if self.expiresAt.len() > 64 { return Err(ValidationError::new("expires_at", "max_length")); }
|
if self.expiresAt.len() > 64 { return Err(ValidationError::new("expires_at", "max_length")); }
|
||||||
|
if !valid_rfc3339_utc(self.expiresAt.as_str()) { return Err(ValidationError::new("expires_at", "invalid_time")); }
|
||||||
if self.audience.is_empty() { return Err(ValidationError::new("audience", "required")); }
|
if self.audience.is_empty() { return Err(ValidationError::new("audience", "required")); }
|
||||||
if !self.audience.is_empty() && self.audience.len() < 1 { return Err(ValidationError::new("audience", "min_length")); }
|
if !self.audience.is_empty() && self.audience.len() < 1 { return Err(ValidationError::new("audience", "min_length")); }
|
||||||
if self.audience.len() > 128 { return Err(ValidationError::new("audience", "max_length")); }
|
if self.audience.len() > 128 { return Err(ValidationError::new("audience", "max_length")); }
|
||||||
@@ -1066,6 +1203,8 @@ impl ManifestGateway {
|
|||||||
if self.id.len() > 128 { return Err(ValidationError::new("id", "max_length")); }
|
if self.id.len() > 128 { return Err(ValidationError::new("id", "max_length")); }
|
||||||
if self.addresses.len() < 1 { return Err(ValidationError::new("addresses", "min_items")); }
|
if self.addresses.len() < 1 { return Err(ValidationError::new("addresses", "min_items")); }
|
||||||
if self.addresses.len() > 4 { return Err(ValidationError::new("addresses", "max_items")); }
|
if self.addresses.len() > 4 { return Err(ValidationError::new("addresses", "max_items")); }
|
||||||
|
for item in self.addresses.iter() { if item.as_bytes().len() < 1 { return Err(ValidationError::new("addresses", "min_item_length")); } }
|
||||||
|
for item in self.addresses.iter() { if item.as_bytes().len() > 256 { return Err(ValidationError::new("addresses", "max_item_length")); } }
|
||||||
if self.publicIdentity.is_empty() { return Err(ValidationError::new("public_identity", "required")); }
|
if self.publicIdentity.is_empty() { return Err(ValidationError::new("public_identity", "required")); }
|
||||||
if !self.publicIdentity.is_empty() && self.publicIdentity.len() < 1 { return Err(ValidationError::new("public_identity", "min_length")); }
|
if !self.publicIdentity.is_empty() && self.publicIdentity.len() < 1 { return Err(ValidationError::new("public_identity", "min_length")); }
|
||||||
if self.publicIdentity.len() > 256 { return Err(ValidationError::new("public_identity", "max_length")); }
|
if self.publicIdentity.len() > 256 { return Err(ValidationError::new("public_identity", "max_length")); }
|
||||||
@@ -1080,11 +1219,12 @@ impl ManifestGateway {
|
|||||||
pub struct ManifestProfile {
|
pub struct ManifestProfile {
|
||||||
id: String,
|
id: String,
|
||||||
bounds: ManifestBounds,
|
bounds: ManifestBounds,
|
||||||
|
displayMode: Option<DisplayMode>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ManifestProfile {
|
impl ManifestProfile {
|
||||||
pub fn new(id: String, bounds: ManifestBounds) -> Result<Self, ValidationError> {
|
pub fn new(id: String, bounds: ManifestBounds, displayMode: Option<DisplayMode>) -> Result<Self, ValidationError> {
|
||||||
let value = Self { id, bounds };
|
let value = Self { id, bounds, displayMode };
|
||||||
value.validate()?;
|
value.validate()?;
|
||||||
Ok(value)
|
Ok(value)
|
||||||
}
|
}
|
||||||
@@ -1093,10 +1233,14 @@ impl ManifestProfile {
|
|||||||
if !self.id.is_empty() && self.id.len() < 1 { return Err(ValidationError::new("id", "min_length")); }
|
if !self.id.is_empty() && self.id.len() < 1 { return Err(ValidationError::new("id", "min_length")); }
|
||||||
if self.id.len() > 128 { return Err(ValidationError::new("id", "max_length")); }
|
if self.id.len() > 128 { return Err(ValidationError::new("id", "max_length")); }
|
||||||
self.bounds.validate().map_err(|_| ValidationError::new("bounds", "invalid_object"))?;
|
self.bounds.validate().map_err(|_| ValidationError::new("bounds", "invalid_object"))?;
|
||||||
|
if let Some(value) = &self.displayMode {
|
||||||
|
value.validate().map_err(|_| ValidationError::new("display_mode", "invalid_object"))?;
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
pub fn id(&self) -> &String { &self.id }
|
pub fn id(&self) -> &String { &self.id }
|
||||||
pub fn bounds(&self) -> &ManifestBounds { &self.bounds }
|
pub fn bounds(&self) -> &ManifestBounds { &self.bounds }
|
||||||
|
pub fn displayMode(&self) -> &Option<DisplayMode> { &self.displayMode }
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -1114,13 +1258,54 @@ impl ManifestTunnel {
|
|||||||
pub fn validate(&self) -> Result<(), ValidationError> {
|
pub fn validate(&self) -> Result<(), ValidationError> {
|
||||||
if self.versions.len() < 1 { return Err(ValidationError::new("versions", "min_items")); }
|
if self.versions.len() < 1 { return Err(ValidationError::new("versions", "min_items")); }
|
||||||
if self.versions.len() > 4 { return Err(ValidationError::new("versions", "max_items")); }
|
if self.versions.len() > 4 { return Err(ValidationError::new("versions", "max_items")); }
|
||||||
|
for item in self.versions.iter() { if item.as_bytes().len() < 1 { return Err(ValidationError::new("versions", "min_item_length")); } }
|
||||||
|
for item in self.versions.iter() { if item.as_bytes().len() > 64 { return Err(ValidationError::new("versions", "max_item_length")); } }
|
||||||
if self.features.len() > 32 { return Err(ValidationError::new("features", "max_items")); }
|
if self.features.len() > 32 { return Err(ValidationError::new("features", "max_items")); }
|
||||||
|
for item in self.features.iter() { if item.as_bytes().len() < 1 { return Err(ValidationError::new("features", "min_item_length")); } }
|
||||||
|
for item in self.features.iter() { if item.as_bytes().len() > 64 { return Err(ValidationError::new("features", "max_item_length")); } }
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
pub fn versions(&self) -> &Vec<String> { &self.versions }
|
pub fn versions(&self) -> &Vec<String> { &self.versions }
|
||||||
pub fn features(&self) -> &Vec<String> { &self.features }
|
pub fn features(&self) -> &Vec<String> { &self.features }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct NativeAuthenticatedSession {
|
||||||
|
username: String,
|
||||||
|
provider: String,
|
||||||
|
roles: Vec<String>,
|
||||||
|
role: String,
|
||||||
|
nativeIdentity: NativeSessionIdentity,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NativeAuthenticatedSession {
|
||||||
|
pub fn new(username: String, provider: String, roles: Vec<String>, role: String, nativeIdentity: NativeSessionIdentity) -> Result<Self, ValidationError> {
|
||||||
|
let value = Self { username, provider, roles, role, nativeIdentity };
|
||||||
|
value.validate()?;
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
pub fn validate(&self) -> Result<(), ValidationError> {
|
||||||
|
if self.username.is_empty() { return Err(ValidationError::new("username", "required")); }
|
||||||
|
if !self.username.is_empty() && self.username.len() < 1 { return Err(ValidationError::new("username", "min_length")); }
|
||||||
|
if self.username.len() > 256 { return Err(ValidationError::new("username", "max_length")); }
|
||||||
|
if self.provider.is_empty() { return Err(ValidationError::new("provider", "required")); }
|
||||||
|
if !self.provider.is_empty() && self.provider.len() < 1 { return Err(ValidationError::new("provider", "min_length")); }
|
||||||
|
if self.provider.len() > 64 { return Err(ValidationError::new("provider", "max_length")); }
|
||||||
|
if self.roles.len() > 16 { return Err(ValidationError::new("roles", "max_items")); }
|
||||||
|
for item in self.roles.iter() { if item.as_bytes().len() < 1 { return Err(ValidationError::new("roles", "min_item_length")); } }
|
||||||
|
for item in self.roles.iter() { if item.as_bytes().len() > 64 { return Err(ValidationError::new("roles", "max_item_length")); } }
|
||||||
|
for item in self.roles.iter() { if item.as_bytes().len() > 64 { return Err(ValidationError::new("roles", "max_item_bytes")); } }
|
||||||
|
if self.role != "user" && self.role != "admin" { return Err(ValidationError::new("role", "invalid_value")); }
|
||||||
|
self.nativeIdentity.validate().map_err(|_| ValidationError::new("native_identity", "invalid_object"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
pub fn username(&self) -> &String { &self.username }
|
||||||
|
pub fn provider(&self) -> &String { &self.provider }
|
||||||
|
pub fn roles(&self) -> &Vec<String> { &self.roles }
|
||||||
|
pub fn role(&self) -> &String { &self.role }
|
||||||
|
pub fn nativeIdentity(&self) -> &NativeSessionIdentity { &self.nativeIdentity }
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct NativeCredential {
|
pub struct NativeCredential {
|
||||||
deviceId: Option<String>,
|
deviceId: Option<String>,
|
||||||
@@ -1151,8 +1336,10 @@ impl NativeCredential {
|
|||||||
if !self.refreshToken.is_empty() && self.refreshToken.len() < 1 { return Err(ValidationError::new("refresh_token", "min_length")); }
|
if !self.refreshToken.is_empty() && self.refreshToken.len() < 1 { return Err(ValidationError::new("refresh_token", "min_length")); }
|
||||||
if self.refreshToken.len() > 256 { return Err(ValidationError::new("refresh_token", "max_length")); }
|
if self.refreshToken.len() > 256 { return Err(ValidationError::new("refresh_token", "max_length")); }
|
||||||
if self.expiresAt.len() > 64 { return Err(ValidationError::new("expires_at", "max_length")); }
|
if self.expiresAt.len() > 64 { return Err(ValidationError::new("expires_at", "max_length")); }
|
||||||
|
if !valid_rfc3339_utc(self.expiresAt.as_str()) { return Err(ValidationError::new("expires_at", "invalid_time")); }
|
||||||
if let Some(value) = &self.refreshExpiresAt {
|
if let Some(value) = &self.refreshExpiresAt {
|
||||||
if value.len() > 64 { return Err(ValidationError::new("refresh_expires_at", "max_length")); }
|
if value.len() > 64 { return Err(ValidationError::new("refresh_expires_at", "max_length")); }
|
||||||
|
if !valid_rfc3339_utc(value.as_str()) { return Err(ValidationError::new("refresh_expires_at", "invalid_time")); }
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1164,6 +1351,70 @@ impl NativeCredential {
|
|||||||
pub fn refreshExpiresAt(&self) -> &Option<String> { &self.refreshExpiresAt }
|
pub fn refreshExpiresAt(&self) -> &Option<String> { &self.refreshExpiresAt }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct NativeSessionIdentity {
|
||||||
|
clientDeviceId: String,
|
||||||
|
deviceKeyId: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NativeSessionIdentity {
|
||||||
|
pub fn new(clientDeviceId: String, deviceKeyId: String) -> Result<Self, ValidationError> {
|
||||||
|
let value = Self { clientDeviceId, deviceKeyId };
|
||||||
|
value.validate()?;
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
pub fn validate(&self) -> Result<(), ValidationError> {
|
||||||
|
if self.clientDeviceId.is_empty() { return Err(ValidationError::new("client_device_id", "required")); }
|
||||||
|
if !self.clientDeviceId.is_empty() && self.clientDeviceId.len() < 1 { return Err(ValidationError::new("client_device_id", "min_length")); }
|
||||||
|
if self.clientDeviceId.len() > 128 { return Err(ValidationError::new("client_device_id", "max_length")); }
|
||||||
|
if self.deviceKeyId.is_empty() { return Err(ValidationError::new("device_key_id", "required")); }
|
||||||
|
if !self.deviceKeyId.is_empty() && self.deviceKeyId.len() < 1 { return Err(ValidationError::new("device_key_id", "min_length")); }
|
||||||
|
if self.deviceKeyId.len() > 128 { return Err(ValidationError::new("device_key_id", "max_length")); }
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
pub fn clientDeviceId(&self) -> &String { &self.clientDeviceId }
|
||||||
|
pub fn deviceKeyId(&self) -> &String { &self.deviceKeyId }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct NativeTunnelCredential {
|
||||||
|
clientDeviceId: String,
|
||||||
|
deviceKeyId: String,
|
||||||
|
certificateChainPem: String,
|
||||||
|
trustBundlePem: String,
|
||||||
|
expiresAt: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NativeTunnelCredential {
|
||||||
|
pub fn new(clientDeviceId: String, deviceKeyId: String, certificateChainPem: String, trustBundlePem: String, expiresAt: String) -> Result<Self, ValidationError> {
|
||||||
|
let value = Self { clientDeviceId, deviceKeyId, certificateChainPem, trustBundlePem, expiresAt };
|
||||||
|
value.validate()?;
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
pub fn validate(&self) -> Result<(), ValidationError> {
|
||||||
|
if self.clientDeviceId.is_empty() { return Err(ValidationError::new("client_device_id", "required")); }
|
||||||
|
if !self.clientDeviceId.is_empty() && self.clientDeviceId.len() < 1 { return Err(ValidationError::new("client_device_id", "min_length")); }
|
||||||
|
if self.clientDeviceId.len() > 128 { return Err(ValidationError::new("client_device_id", "max_length")); }
|
||||||
|
if self.deviceKeyId.is_empty() { return Err(ValidationError::new("device_key_id", "required")); }
|
||||||
|
if !self.deviceKeyId.is_empty() && self.deviceKeyId.len() < 1 { return Err(ValidationError::new("device_key_id", "min_length")); }
|
||||||
|
if self.deviceKeyId.len() > 128 { return Err(ValidationError::new("device_key_id", "max_length")); }
|
||||||
|
if self.certificateChainPem.is_empty() { return Err(ValidationError::new("certificate_chain_pem", "required")); }
|
||||||
|
if !self.certificateChainPem.is_empty() && self.certificateChainPem.len() < 1 { return Err(ValidationError::new("certificate_chain_pem", "min_length")); }
|
||||||
|
if self.certificateChainPem.len() > 65536 { return Err(ValidationError::new("certificate_chain_pem", "max_length")); }
|
||||||
|
if self.trustBundlePem.is_empty() { return Err(ValidationError::new("trust_bundle_pem", "required")); }
|
||||||
|
if !self.trustBundlePem.is_empty() && self.trustBundlePem.len() < 1 { return Err(ValidationError::new("trust_bundle_pem", "min_length")); }
|
||||||
|
if self.trustBundlePem.len() > 65536 { return Err(ValidationError::new("trust_bundle_pem", "max_length")); }
|
||||||
|
if self.expiresAt.len() > 64 { return Err(ValidationError::new("expires_at", "max_length")); }
|
||||||
|
if !valid_rfc3339_utc(self.expiresAt.as_str()) { return Err(ValidationError::new("expires_at", "invalid_time")); }
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
pub fn clientDeviceId(&self) -> &String { &self.clientDeviceId }
|
||||||
|
pub fn deviceKeyId(&self) -> &String { &self.deviceKeyId }
|
||||||
|
pub fn certificateChainPem(&self) -> &String { &self.certificateChainPem }
|
||||||
|
pub fn trustBundlePem(&self) -> &String { &self.trustBundlePem }
|
||||||
|
pub fn expiresAt(&self) -> &String { &self.expiresAt }
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct PageInfo {
|
pub struct PageInfo {
|
||||||
limit: i64,
|
limit: i64,
|
||||||
@@ -1226,6 +1477,7 @@ impl ProviderSessionWork {
|
|||||||
if self.gatewayId.len() > 128 { return Err(ValidationError::new("gateway_id", "max_length")); }
|
if self.gatewayId.len() > 128 { return Err(ValidationError::new("gateway_id", "max_length")); }
|
||||||
if self.reconnectSequence < 0 { return Err(ValidationError::new("reconnect_sequence", "minimum")); }
|
if self.reconnectSequence < 0 { return Err(ValidationError::new("reconnect_sequence", "minimum")); }
|
||||||
if self.expiresAt.len() > 64 { return Err(ValidationError::new("expires_at", "max_length")); }
|
if self.expiresAt.len() > 64 { return Err(ValidationError::new("expires_at", "max_length")); }
|
||||||
|
if !valid_rfc3339_utc(self.expiresAt.as_str()) { return Err(ValidationError::new("expires_at", "invalid_time")); }
|
||||||
if self.providerProfile != "apollo" { return Err(ValidationError::new("provider_profile", "invalid_value")); }
|
if self.providerProfile != "apollo" { return Err(ValidationError::new("provider_profile", "invalid_value")); }
|
||||||
if self.providerIdentity.is_empty() { return Err(ValidationError::new("provider_identity", "required")); }
|
if self.providerIdentity.is_empty() { return Err(ValidationError::new("provider_identity", "required")); }
|
||||||
if !self.providerIdentity.is_empty() && self.providerIdentity.len() < 1 { return Err(ValidationError::new("provider_identity", "min_length")); }
|
if !self.providerIdentity.is_empty() && self.providerIdentity.len() < 1 { return Err(ValidationError::new("provider_identity", "min_length")); }
|
||||||
@@ -1306,6 +1558,8 @@ impl ProviderState {
|
|||||||
if self.sessionId.len() > 128 { return Err(ValidationError::new("session_id", "max_length")); }
|
if self.sessionId.len() > 128 { return Err(ValidationError::new("session_id", "max_length")); }
|
||||||
if self.state != "starting" && self.state != "ready" && self.state != "disconnected" && self.state != "terminating" && self.state != "terminated" && self.state != "cleanup_pending" && self.state != "failed" { return Err(ValidationError::new("state", "invalid_value")); }
|
if self.state != "starting" && self.state != "ready" && self.state != "disconnected" && self.state != "terminating" && self.state != "terminated" && self.state != "cleanup_pending" && self.state != "failed" { return Err(ValidationError::new("state", "invalid_value")); }
|
||||||
if self.channels.len() > 8 { return Err(ValidationError::new("channels", "max_items")); }
|
if self.channels.len() > 8 { return Err(ValidationError::new("channels", "max_items")); }
|
||||||
|
for item in self.channels.iter() { if item.as_bytes().len() < 1 { return Err(ValidationError::new("channels", "min_item_length")); } }
|
||||||
|
for item in self.channels.iter() { if item.as_bytes().len() > 64 { return Err(ValidationError::new("channels", "max_item_length")); } }
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
pub fn version(&self) -> &String { &self.version }
|
pub fn version(&self) -> &String { &self.version }
|
||||||
@@ -1372,6 +1626,7 @@ impl ReauthGrant {
|
|||||||
if !self.purpose.is_empty() && self.purpose.len() < 1 { return Err(ValidationError::new("purpose", "min_length")); }
|
if !self.purpose.is_empty() && self.purpose.len() < 1 { return Err(ValidationError::new("purpose", "min_length")); }
|
||||||
if self.purpose.len() > 64 { return Err(ValidationError::new("purpose", "max_length")); }
|
if self.purpose.len() > 64 { return Err(ValidationError::new("purpose", "max_length")); }
|
||||||
if self.expiresAt.len() > 64 { return Err(ValidationError::new("expires_at", "max_length")); }
|
if self.expiresAt.len() > 64 { return Err(ValidationError::new("expires_at", "max_length")); }
|
||||||
|
if !valid_rfc3339_utc(self.expiresAt.as_str()) { return Err(ValidationError::new("expires_at", "invalid_time")); }
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
pub fn token(&self) -> &String { &self.token }
|
pub fn token(&self) -> &String { &self.token }
|
||||||
@@ -1588,6 +1843,7 @@ impl SessionAuthority {
|
|||||||
if self.audience.len() > 256 { return Err(ValidationError::new("audience", "max_length")); }
|
if self.audience.len() > 256 { return Err(ValidationError::new("audience", "max_length")); }
|
||||||
if self.reconnectSequence < 0 { return Err(ValidationError::new("reconnect_sequence", "minimum")); }
|
if self.reconnectSequence < 0 { return Err(ValidationError::new("reconnect_sequence", "minimum")); }
|
||||||
if self.expiresAt.len() > 64 { return Err(ValidationError::new("expires_at", "max_length")); }
|
if self.expiresAt.len() > 64 { return Err(ValidationError::new("expires_at", "max_length")); }
|
||||||
|
if !valid_rfc3339_utc(self.expiresAt.as_str()) { return Err(ValidationError::new("expires_at", "invalid_time")); }
|
||||||
self.capabilities.validate().map_err(|_| ValidationError::new("capabilities", "invalid_object"))?;
|
self.capabilities.validate().map_err(|_| ValidationError::new("capabilities", "invalid_object"))?;
|
||||||
if self.providerProfile != "apollo" { return Err(ValidationError::new("provider_profile", "invalid_value")); }
|
if self.providerProfile != "apollo" { return Err(ValidationError::new("provider_profile", "invalid_value")); }
|
||||||
if self.providerIdentity.is_empty() { return Err(ValidationError::new("provider_identity", "required")); }
|
if self.providerIdentity.is_empty() { return Err(ValidationError::new("provider_identity", "required")); }
|
||||||
@@ -1612,12 +1868,12 @@ pub struct SessionRequest {
|
|||||||
deviceKeyId: String,
|
deviceKeyId: String,
|
||||||
poolId: String,
|
poolId: String,
|
||||||
idempotencyKey: String,
|
idempotencyKey: String,
|
||||||
policySnapshot: AllocationPolicy,
|
requestedDisplayMode: Option<DisplayMode>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SessionRequest {
|
impl SessionRequest {
|
||||||
pub fn new(clientDeviceId: String, deviceKeyId: String, poolId: String, idempotencyKey: String, policySnapshot: AllocationPolicy) -> Result<Self, ValidationError> {
|
pub fn new(clientDeviceId: String, deviceKeyId: String, poolId: String, idempotencyKey: String, requestedDisplayMode: Option<DisplayMode>) -> Result<Self, ValidationError> {
|
||||||
let value = Self { clientDeviceId, deviceKeyId, poolId, idempotencyKey, policySnapshot };
|
let value = Self { clientDeviceId, deviceKeyId, poolId, idempotencyKey, requestedDisplayMode };
|
||||||
value.validate()?;
|
value.validate()?;
|
||||||
Ok(value)
|
Ok(value)
|
||||||
}
|
}
|
||||||
@@ -1634,14 +1890,16 @@ impl SessionRequest {
|
|||||||
if self.idempotencyKey.is_empty() { return Err(ValidationError::new("idempotency_key", "required")); }
|
if self.idempotencyKey.is_empty() { return Err(ValidationError::new("idempotency_key", "required")); }
|
||||||
if !self.idempotencyKey.is_empty() && self.idempotencyKey.len() < 1 { return Err(ValidationError::new("idempotency_key", "min_length")); }
|
if !self.idempotencyKey.is_empty() && self.idempotencyKey.len() < 1 { return Err(ValidationError::new("idempotency_key", "min_length")); }
|
||||||
if self.idempotencyKey.len() > 256 { return Err(ValidationError::new("idempotency_key", "max_length")); }
|
if self.idempotencyKey.len() > 256 { return Err(ValidationError::new("idempotency_key", "max_length")); }
|
||||||
self.policySnapshot.validate().map_err(|_| ValidationError::new("policy_snapshot", "invalid_object"))?;
|
if let Some(value) = &self.requestedDisplayMode {
|
||||||
|
value.validate().map_err(|_| ValidationError::new("requested_display_mode", "invalid_object"))?;
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
pub fn clientDeviceId(&self) -> &String { &self.clientDeviceId }
|
pub fn clientDeviceId(&self) -> &String { &self.clientDeviceId }
|
||||||
pub fn deviceKeyId(&self) -> &String { &self.deviceKeyId }
|
pub fn deviceKeyId(&self) -> &String { &self.deviceKeyId }
|
||||||
pub fn poolId(&self) -> &String { &self.poolId }
|
pub fn poolId(&self) -> &String { &self.poolId }
|
||||||
pub fn idempotencyKey(&self) -> &String { &self.idempotencyKey }
|
pub fn idempotencyKey(&self) -> &String { &self.idempotencyKey }
|
||||||
pub fn policySnapshot(&self) -> &AllocationPolicy { &self.policySnapshot }
|
pub fn requestedDisplayMode(&self) -> &Option<DisplayMode> { &self.requestedDisplayMode }
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -1752,13 +2010,30 @@ impl VersionNegotiation {
|
|||||||
pub fn validate(&self) -> Result<(), ValidationError> {
|
pub fn validate(&self) -> Result<(), ValidationError> {
|
||||||
if self.supportedVersions.len() < 1 { return Err(ValidationError::new("supported_versions", "min_items")); }
|
if self.supportedVersions.len() < 1 { return Err(ValidationError::new("supported_versions", "min_items")); }
|
||||||
if self.supportedVersions.len() > 3 { return Err(ValidationError::new("supported_versions", "max_items")); }
|
if self.supportedVersions.len() > 3 { return Err(ValidationError::new("supported_versions", "max_items")); }
|
||||||
|
for item in self.supportedVersions.iter() { if item.as_bytes().len() > 16 { return Err(ValidationError::new("supported_versions", "max_item_length")); } }
|
||||||
if self.features.len() > 64 { return Err(ValidationError::new("features", "max_items")); }
|
if self.features.len() > 64 { return Err(ValidationError::new("features", "max_items")); }
|
||||||
|
for item in self.features.iter() { if item.as_bytes().len() > 64 { return Err(ValidationError::new("features", "max_item_length")); } }
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
pub fn supportedVersions(&self) -> &Vec<String> { &self.supportedVersions }
|
pub fn supportedVersions(&self) -> &Vec<String> { &self.supportedVersions }
|
||||||
pub fn features(&self) -> &Vec<String> { &self.features }
|
pub fn features(&self) -> &Vec<String> { &self.features }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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> {
|
pub fn intersect_capability_profiles(profiles: &[CapabilityProfile]) -> Result<CapabilityProfile, ValidationError> {
|
||||||
let mut selected = profiles.first().ok_or_else(|| ValidationError::new("capabilities", "no_overlap"))?.clone();
|
let mut selected = profiles.first().ok_or_else(|| ValidationError::new("capabilities", "no_overlap"))?.clone();
|
||||||
selected.validate().map_err(|_| ValidationError::new("capabilities", "no_overlap"))?;
|
selected.validate().map_err(|_| ValidationError::new("capabilities", "no_overlap"))?;
|
||||||
|
|||||||
+364
-27
@@ -1,10 +1,10 @@
|
|||||||
// Code generated by tools/generate.py; DO NOT EDIT.
|
// Code generated by tools/generate.py; DO NOT EDIT.
|
||||||
import Foundation
|
import Foundation
|
||||||
public typealias JSONObject = [String: String]
|
public typealias JSONObject = [String: String]
|
||||||
public let schemaSHA256 = "a86cbdaf2cfb884e3d98467968007e731ca55c6f6eb6dbd5cd6b95e062a9b058"
|
public let schemaSHA256 = "b2353c12269304289b4e872f27cc370ae61b958dea90d9fb7b6ab8afd7d37248"
|
||||||
public let currentWireVersion = "1"
|
public let currentWireVersion = "2"
|
||||||
public let nMinus1WireVersion = "0"
|
public let nMinus1WireVersion = "1"
|
||||||
public let nMinus2WireVersion = "-1"
|
public let nMinus2WireVersion = "0"
|
||||||
public struct ContractValidationError: Error, Equatable { public let field: String; public let code: String }
|
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 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 {
|
private func validBase64URL(_ value: String) -> Bool {
|
||||||
@@ -16,6 +16,23 @@ private func validBase64URL(_ value: String) -> Bool {
|
|||||||
guard let decoded = Data(base64Encoded: standard) else { return false }
|
guard let decoded = Data(base64Encoded: standard) else { return false }
|
||||||
return decoded.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "") == value
|
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
|
||||||
|
}
|
||||||
|
|
||||||
public struct AllocationPolicy: Codable, Equatable {
|
public struct AllocationPolicy: Codable, Equatable {
|
||||||
public let minimumKbps: Int64
|
public let minimumKbps: Int64
|
||||||
@@ -148,6 +165,8 @@ public struct BrokerSession: Codable, Equatable {
|
|||||||
public let requestedAt: String
|
public let requestedAt: String
|
||||||
public let endedAt: String?
|
public let endedAt: String?
|
||||||
public let version: Int64
|
public let version: Int64
|
||||||
|
public let requestedDisplayMode: DisplayMode?
|
||||||
|
public let effectiveDisplayMode: DisplayMode?
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id = "id"
|
case id = "id"
|
||||||
case principalId = "principal_id"
|
case principalId = "principal_id"
|
||||||
@@ -164,9 +183,11 @@ public struct BrokerSession: Codable, Equatable {
|
|||||||
case requestedAt = "requested_at"
|
case requestedAt = "requested_at"
|
||||||
case endedAt = "ended_at"
|
case endedAt = "ended_at"
|
||||||
case version = "version"
|
case version = "version"
|
||||||
|
case requestedDisplayMode = "requested_display_mode"
|
||||||
|
case effectiveDisplayMode = "effective_display_mode"
|
||||||
}
|
}
|
||||||
|
|
||||||
public init(id: String, principalId: String, poolId: String, assignmentId: String?, state: String, policySnapshot: AllocationPolicy, reconnectDeadline: String?, outcome: String?, failureCode: String?, cleanupState: String, idempotencyKey: String, correlationId: String, requestedAt: String, endedAt: String?, version: Int64) throws {
|
public init(id: String, principalId: String, poolId: String, assignmentId: String?, state: String, policySnapshot: AllocationPolicy, reconnectDeadline: String?, outcome: String?, failureCode: String?, cleanupState: String, idempotencyKey: String, correlationId: String, requestedAt: String, endedAt: String?, version: Int64, requestedDisplayMode: DisplayMode?, effectiveDisplayMode: DisplayMode?) throws {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.principalId = principalId
|
self.principalId = principalId
|
||||||
self.poolId = poolId
|
self.poolId = poolId
|
||||||
@@ -182,6 +203,8 @@ public struct BrokerSession: Codable, Equatable {
|
|||||||
self.requestedAt = requestedAt
|
self.requestedAt = requestedAt
|
||||||
self.endedAt = endedAt
|
self.endedAt = endedAt
|
||||||
self.version = version
|
self.version = version
|
||||||
|
self.requestedDisplayMode = requestedDisplayMode
|
||||||
|
self.effectiveDisplayMode = effectiveDisplayMode
|
||||||
try validate()
|
try validate()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +212,7 @@ public struct BrokerSession: Codable, Equatable {
|
|||||||
let all = try decoder.container(keyedBy: AnyCodingKey.self)
|
let all = try decoder.container(keyedBy: AnyCodingKey.self)
|
||||||
for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") }
|
for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") }
|
||||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
try self.init(id: try c.decode(String.self, forKey: .id), principalId: try c.decode(String.self, forKey: .principalId), poolId: try c.decode(String.self, forKey: .poolId), assignmentId: try c.decodeIfPresent(String.self, forKey: .assignmentId), state: try c.decode(String.self, forKey: .state), policySnapshot: try c.decode(AllocationPolicy.self, forKey: .policySnapshot), reconnectDeadline: try c.decodeIfPresent(String.self, forKey: .reconnectDeadline), outcome: try c.decodeIfPresent(String.self, forKey: .outcome), failureCode: try c.decodeIfPresent(String.self, forKey: .failureCode), cleanupState: try c.decode(String.self, forKey: .cleanupState), idempotencyKey: try c.decode(String.self, forKey: .idempotencyKey), correlationId: try c.decode(String.self, forKey: .correlationId), requestedAt: try c.decode(String.self, forKey: .requestedAt), endedAt: try c.decodeIfPresent(String.self, forKey: .endedAt), version: try c.decode(Int64.self, forKey: .version))
|
try self.init(id: try c.decode(String.self, forKey: .id), principalId: try c.decode(String.self, forKey: .principalId), poolId: try c.decode(String.self, forKey: .poolId), assignmentId: try c.decodeIfPresent(String.self, forKey: .assignmentId), state: try c.decode(String.self, forKey: .state), policySnapshot: try c.decode(AllocationPolicy.self, forKey: .policySnapshot), reconnectDeadline: try c.decodeIfPresent(String.self, forKey: .reconnectDeadline), outcome: try c.decodeIfPresent(String.self, forKey: .outcome), failureCode: try c.decodeIfPresent(String.self, forKey: .failureCode), cleanupState: try c.decode(String.self, forKey: .cleanupState), idempotencyKey: try c.decode(String.self, forKey: .idempotencyKey), correlationId: try c.decode(String.self, forKey: .correlationId), requestedAt: try c.decode(String.self, forKey: .requestedAt), endedAt: try c.decodeIfPresent(String.self, forKey: .endedAt), version: try c.decode(Int64.self, forKey: .version), requestedDisplayMode: try c.contains(.requestedDisplayMode) ? c.decode(DisplayMode.self, forKey: .requestedDisplayMode) : nil, effectiveDisplayMode: try c.contains(.effectiveDisplayMode) ? c.decode(DisplayMode.self, forKey: .effectiveDisplayMode) : nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func validate() throws {
|
public func validate() throws {
|
||||||
@@ -211,7 +234,7 @@ public struct BrokerSession: Codable, Equatable {
|
|||||||
try self.policySnapshot.validate()
|
try self.policySnapshot.validate()
|
||||||
if let value = self.reconnectDeadline {
|
if let value = self.reconnectDeadline {
|
||||||
if value.utf8.count > 64 { throw ContractValidationError(field: "reconnect_deadline", code: "max_length") }
|
if value.utf8.count > 64 { throw ContractValidationError(field: "reconnect_deadline", code: "max_length") }
|
||||||
if ISO8601DateFormatter().date(from: value) == nil { throw ContractValidationError(field: "reconnect_deadline", code: "invalid_time") }
|
if !validRFC3339UTC(value) { throw ContractValidationError(field: "reconnect_deadline", code: "invalid_time") }
|
||||||
}
|
}
|
||||||
if let value = self.outcome {
|
if let value = self.outcome {
|
||||||
if value.utf8.count > 64 { throw ContractValidationError(field: "outcome", code: "max_length") }
|
if value.utf8.count > 64 { throw ContractValidationError(field: "outcome", code: "max_length") }
|
||||||
@@ -229,12 +252,63 @@ public struct BrokerSession: Codable, Equatable {
|
|||||||
if !self.correlationId.isEmpty && self.correlationId.utf8.count < 1 { throw ContractValidationError(field: "correlation_id", code: "min_length") }
|
if !self.correlationId.isEmpty && self.correlationId.utf8.count < 1 { throw ContractValidationError(field: "correlation_id", code: "min_length") }
|
||||||
if self.correlationId.utf8.count > 128 { throw ContractValidationError(field: "correlation_id", code: "max_length") }
|
if self.correlationId.utf8.count > 128 { throw ContractValidationError(field: "correlation_id", code: "max_length") }
|
||||||
if self.requestedAt.utf8.count > 64 { throw ContractValidationError(field: "requested_at", code: "max_length") }
|
if self.requestedAt.utf8.count > 64 { throw ContractValidationError(field: "requested_at", code: "max_length") }
|
||||||
if ISO8601DateFormatter().date(from: self.requestedAt) == nil { throw ContractValidationError(field: "requested_at", code: "invalid_time") }
|
if !validRFC3339UTC(self.requestedAt) { throw ContractValidationError(field: "requested_at", code: "invalid_time") }
|
||||||
if let value = self.endedAt {
|
if let value = self.endedAt {
|
||||||
if value.utf8.count > 64 { throw ContractValidationError(field: "ended_at", code: "max_length") }
|
if value.utf8.count > 64 { throw ContractValidationError(field: "ended_at", code: "max_length") }
|
||||||
if ISO8601DateFormatter().date(from: value) == nil { throw ContractValidationError(field: "ended_at", code: "invalid_time") }
|
if !validRFC3339UTC(value) { throw ContractValidationError(field: "ended_at", code: "invalid_time") }
|
||||||
}
|
}
|
||||||
if self.version < 1 { throw ContractValidationError(field: "version", code: "minimum") }
|
if self.version < 1 { throw ContractValidationError(field: "version", code: "minimum") }
|
||||||
|
if let value = self.requestedDisplayMode {
|
||||||
|
try value.validate()
|
||||||
|
}
|
||||||
|
if let value = self.effectiveDisplayMode {
|
||||||
|
try value.validate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }
|
||||||
|
public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct BrowserAuthenticatedSession: Codable, Equatable {
|
||||||
|
public let username: String
|
||||||
|
public let provider: String
|
||||||
|
public let roles: [String]
|
||||||
|
public let role: String
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case username = "username"
|
||||||
|
case provider = "provider"
|
||||||
|
case roles = "roles"
|
||||||
|
case role = "role"
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(username: String, provider: String, roles: [String], role: String) throws {
|
||||||
|
self.username = username
|
||||||
|
self.provider = provider
|
||||||
|
self.roles = roles
|
||||||
|
self.role = role
|
||||||
|
try validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let all = try decoder.container(keyedBy: AnyCodingKey.self)
|
||||||
|
for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") }
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try self.init(username: try c.decode(String.self, forKey: .username), provider: try c.decode(String.self, forKey: .provider), roles: try c.decode([String].self, forKey: .roles), role: try c.decode(String.self, forKey: .role))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func validate() throws {
|
||||||
|
if self.username.isEmpty { throw ContractValidationError(field: "username", code: "required") }
|
||||||
|
if !self.username.isEmpty && self.username.utf8.count < 1 { throw ContractValidationError(field: "username", code: "min_length") }
|
||||||
|
if self.username.utf8.count > 256 { throw ContractValidationError(field: "username", code: "max_length") }
|
||||||
|
if self.provider.isEmpty { throw ContractValidationError(field: "provider", code: "required") }
|
||||||
|
if !self.provider.isEmpty && self.provider.utf8.count < 1 { throw ContractValidationError(field: "provider", code: "min_length") }
|
||||||
|
if self.provider.utf8.count > 64 { throw ContractValidationError(field: "provider", code: "max_length") }
|
||||||
|
if self.roles.count > 16 { throw ContractValidationError(field: "roles", code: "max_items") }
|
||||||
|
for item in self.roles where item.utf8.count < 1 { throw ContractValidationError(field: "roles", code: "min_item_length") }
|
||||||
|
for item in self.roles where item.utf8.count > 64 { throw ContractValidationError(field: "roles", code: "max_item_length") }
|
||||||
|
for item in self.roles where item.utf8.count > 64 { throw ContractValidationError(field: "roles", code: "max_item_bytes") }
|
||||||
|
if !["user", "admin"].contains(self.role) { throw ContractValidationError(field: "role", code: "invalid_value") }
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }
|
public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }
|
||||||
@@ -359,6 +433,63 @@ public struct ChannelFrame: Codable, Equatable {
|
|||||||
public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }
|
public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct ClientSessionAuthority: Codable, Equatable {
|
||||||
|
public let version: String
|
||||||
|
public let sessionId: String
|
||||||
|
public let gatewayId: String
|
||||||
|
public let audience: String
|
||||||
|
public let reconnectSequence: Int64
|
||||||
|
public let expiresAt: String
|
||||||
|
public let capabilities: CapabilityProfile
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case version = "version"
|
||||||
|
case sessionId = "session_id"
|
||||||
|
case gatewayId = "gateway_id"
|
||||||
|
case audience = "audience"
|
||||||
|
case reconnectSequence = "reconnect_sequence"
|
||||||
|
case expiresAt = "expires_at"
|
||||||
|
case capabilities = "capabilities"
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(version: String, sessionId: String, gatewayId: String, audience: String, reconnectSequence: Int64, expiresAt: String, capabilities: CapabilityProfile) throws {
|
||||||
|
self.version = version
|
||||||
|
self.sessionId = sessionId
|
||||||
|
self.gatewayId = gatewayId
|
||||||
|
self.audience = audience
|
||||||
|
self.reconnectSequence = reconnectSequence
|
||||||
|
self.expiresAt = expiresAt
|
||||||
|
self.capabilities = capabilities
|
||||||
|
try validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let all = try decoder.container(keyedBy: AnyCodingKey.self)
|
||||||
|
for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") }
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try self.init(version: try c.decode(String.self, forKey: .version), sessionId: try c.decode(String.self, forKey: .sessionId), gatewayId: try c.decode(String.self, forKey: .gatewayId), audience: try c.decode(String.self, forKey: .audience), reconnectSequence: try c.decode(Int64.self, forKey: .reconnectSequence), expiresAt: try c.decode(String.self, forKey: .expiresAt), capabilities: try c.decode(CapabilityProfile.self, forKey: .capabilities))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func validate() throws {
|
||||||
|
if self.version != "1" { throw ContractValidationError(field: "version", code: "invalid_value") }
|
||||||
|
if self.sessionId.isEmpty { throw ContractValidationError(field: "session_id", code: "required") }
|
||||||
|
if !self.sessionId.isEmpty && self.sessionId.utf8.count < 1 { throw ContractValidationError(field: "session_id", code: "min_length") }
|
||||||
|
if self.sessionId.utf8.count > 128 { throw ContractValidationError(field: "session_id", code: "max_length") }
|
||||||
|
if self.gatewayId.isEmpty { throw ContractValidationError(field: "gateway_id", code: "required") }
|
||||||
|
if !self.gatewayId.isEmpty && self.gatewayId.utf8.count < 1 { throw ContractValidationError(field: "gateway_id", code: "min_length") }
|
||||||
|
if self.gatewayId.utf8.count > 128 { throw ContractValidationError(field: "gateway_id", code: "max_length") }
|
||||||
|
if self.audience.isEmpty { throw ContractValidationError(field: "audience", code: "required") }
|
||||||
|
if !self.audience.isEmpty && self.audience.utf8.count < 1 { throw ContractValidationError(field: "audience", code: "min_length") }
|
||||||
|
if self.audience.utf8.count > 256 { throw ContractValidationError(field: "audience", code: "max_length") }
|
||||||
|
if self.reconnectSequence < 0 { throw ContractValidationError(field: "reconnect_sequence", code: "minimum") }
|
||||||
|
if self.expiresAt.utf8.count > 64 { throw ContractValidationError(field: "expires_at", code: "max_length") }
|
||||||
|
if !validRFC3339UTC(self.expiresAt) { throw ContractValidationError(field: "expires_at", code: "invalid_time") }
|
||||||
|
try self.capabilities.validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }
|
||||||
|
public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }
|
||||||
|
}
|
||||||
|
|
||||||
public struct ClipboardPolicy: Codable, Equatable {
|
public struct ClipboardPolicy: Codable, Equatable {
|
||||||
public let clientToProviderEnabled: Bool
|
public let clientToProviderEnabled: Bool
|
||||||
public let providerToClientEnabled: Bool
|
public let providerToClientEnabled: Bool
|
||||||
@@ -539,7 +670,7 @@ public struct DeviceChallenge: Codable, Equatable {
|
|||||||
if !self.challenge.isEmpty && self.challenge.utf8.count < 1 { throw ContractValidationError(field: "challenge", code: "min_length") }
|
if !self.challenge.isEmpty && self.challenge.utf8.count < 1 { throw ContractValidationError(field: "challenge", code: "min_length") }
|
||||||
if self.challenge.utf8.count > 256 { throw ContractValidationError(field: "challenge", code: "max_length") }
|
if self.challenge.utf8.count > 256 { throw ContractValidationError(field: "challenge", code: "max_length") }
|
||||||
if self.expiresAt.utf8.count > 64 { throw ContractValidationError(field: "expires_at", code: "max_length") }
|
if self.expiresAt.utf8.count > 64 { throw ContractValidationError(field: "expires_at", code: "max_length") }
|
||||||
if ISO8601DateFormatter().date(from: self.expiresAt) == nil { throw ContractValidationError(field: "expires_at", code: "invalid_time") }
|
if !validRFC3339UTC(self.expiresAt) { throw ContractValidationError(field: "expires_at", code: "invalid_time") }
|
||||||
if self.algorithm != "ed25519" { throw ContractValidationError(field: "algorithm", code: "invalid_value") }
|
if self.algorithm != "ed25519" { throw ContractValidationError(field: "algorithm", code: "invalid_value") }
|
||||||
if self.signatureFormat != "ed25519-domain-separated-v1" { throw ContractValidationError(field: "signature_format", code: "invalid_value") }
|
if self.signatureFormat != "ed25519-domain-separated-v1" { throw ContractValidationError(field: "signature_format", code: "invalid_value") }
|
||||||
}
|
}
|
||||||
@@ -632,6 +763,43 @@ public struct DeviceRegistrationRequest: Codable, Equatable {
|
|||||||
public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }
|
public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct DisplayMode: Codable, Equatable {
|
||||||
|
public let resolutionWidth: Int64
|
||||||
|
public let resolutionHeight: Int64
|
||||||
|
public let fps: Int64
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case resolutionWidth = "resolution_width"
|
||||||
|
case resolutionHeight = "resolution_height"
|
||||||
|
case fps = "fps"
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(resolutionWidth: Int64, resolutionHeight: Int64, fps: Int64) throws {
|
||||||
|
self.resolutionWidth = resolutionWidth
|
||||||
|
self.resolutionHeight = resolutionHeight
|
||||||
|
self.fps = fps
|
||||||
|
try validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let all = try decoder.container(keyedBy: AnyCodingKey.self)
|
||||||
|
for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") }
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try self.init(resolutionWidth: try c.decode(Int64.self, forKey: .resolutionWidth), resolutionHeight: try c.decode(Int64.self, forKey: .resolutionHeight), fps: try c.decode(Int64.self, forKey: .fps))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func validate() throws {
|
||||||
|
if self.resolutionWidth < 320 { throw ContractValidationError(field: "resolution_width", code: "minimum") }
|
||||||
|
if self.resolutionWidth > 16384 { throw ContractValidationError(field: "resolution_width", code: "maximum") }
|
||||||
|
if self.resolutionHeight < 200 { throw ContractValidationError(field: "resolution_height", code: "minimum") }
|
||||||
|
if self.resolutionHeight > 8640 { throw ContractValidationError(field: "resolution_height", code: "maximum") }
|
||||||
|
if self.fps < 1 { throw ContractValidationError(field: "fps", code: "minimum") }
|
||||||
|
if self.fps > 240 { throw ContractValidationError(field: "fps", code: "maximum") }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }
|
||||||
|
public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }
|
||||||
|
}
|
||||||
|
|
||||||
public struct EntitledPool: Codable, Equatable {
|
public struct EntitledPool: Codable, Equatable {
|
||||||
public let poolId: String
|
public let poolId: String
|
||||||
public let name: String
|
public let name: String
|
||||||
@@ -782,7 +950,7 @@ public struct EventEnvelope: Codable, Equatable {
|
|||||||
if self.version < 1 { throw ContractValidationError(field: "version", code: "minimum") }
|
if self.version < 1 { throw ContractValidationError(field: "version", code: "minimum") }
|
||||||
try self.resource.validate()
|
try self.resource.validate()
|
||||||
if self.occurredAt.utf8.count > 64 { throw ContractValidationError(field: "occurred_at", code: "max_length") }
|
if self.occurredAt.utf8.count > 64 { throw ContractValidationError(field: "occurred_at", code: "max_length") }
|
||||||
if ISO8601DateFormatter().date(from: self.occurredAt) == nil { throw ContractValidationError(field: "occurred_at", code: "invalid_time") }
|
if !validRFC3339UTC(self.occurredAt) { throw ContractValidationError(field: "occurred_at", code: "invalid_time") }
|
||||||
if self.correlationId.isEmpty { throw ContractValidationError(field: "correlation_id", code: "required") }
|
if self.correlationId.isEmpty { throw ContractValidationError(field: "correlation_id", code: "required") }
|
||||||
if !self.correlationId.isEmpty && self.correlationId.utf8.count < 1 { throw ContractValidationError(field: "correlation_id", code: "min_length") }
|
if !self.correlationId.isEmpty && self.correlationId.utf8.count < 1 { throw ContractValidationError(field: "correlation_id", code: "min_length") }
|
||||||
if self.correlationId.utf8.count > 128 { throw ContractValidationError(field: "correlation_id", code: "max_length") }
|
if self.correlationId.utf8.count > 128 { throw ContractValidationError(field: "correlation_id", code: "max_length") }
|
||||||
@@ -987,7 +1155,7 @@ public struct GatewayDrain: Codable, Equatable {
|
|||||||
if !self.reason.isEmpty && self.reason.utf8.count < 1 { throw ContractValidationError(field: "reason", code: "min_length") }
|
if !self.reason.isEmpty && self.reason.utf8.count < 1 { throw ContractValidationError(field: "reason", code: "min_length") }
|
||||||
if self.reason.utf8.count > 256 { throw ContractValidationError(field: "reason", code: "max_length") }
|
if self.reason.utf8.count > 256 { throw ContractValidationError(field: "reason", code: "max_length") }
|
||||||
if self.deadline.utf8.count > 64 { throw ContractValidationError(field: "deadline", code: "max_length") }
|
if self.deadline.utf8.count > 64 { throw ContractValidationError(field: "deadline", code: "max_length") }
|
||||||
if ISO8601DateFormatter().date(from: self.deadline) == nil { throw ContractValidationError(field: "deadline", code: "invalid_time") }
|
if !validRFC3339UTC(self.deadline) { throw ContractValidationError(field: "deadline", code: "invalid_time") }
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }
|
public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }
|
||||||
@@ -1040,7 +1208,7 @@ public struct GatewayHeartbeat: Codable, Equatable {
|
|||||||
if self.gatewayId.utf8.count > 128 { throw ContractValidationError(field: "gateway_id", code: "max_length") }
|
if self.gatewayId.utf8.count > 128 { throw ContractValidationError(field: "gateway_id", code: "max_length") }
|
||||||
if self.sequence < 1 { throw ContractValidationError(field: "sequence", code: "minimum") }
|
if self.sequence < 1 { throw ContractValidationError(field: "sequence", code: "minimum") }
|
||||||
if self.observedAt.utf8.count > 64 { throw ContractValidationError(field: "observed_at", code: "max_length") }
|
if self.observedAt.utf8.count > 64 { throw ContractValidationError(field: "observed_at", code: "max_length") }
|
||||||
if ISO8601DateFormatter().date(from: self.observedAt) == nil { throw ContractValidationError(field: "observed_at", code: "invalid_time") }
|
if !validRFC3339UTC(self.observedAt) { throw ContractValidationError(field: "observed_at", code: "invalid_time") }
|
||||||
if self.activeConnections < 0 { throw ContractValidationError(field: "active_connections", code: "minimum") }
|
if self.activeConnections < 0 { throw ContractValidationError(field: "active_connections", code: "minimum") }
|
||||||
if self.activeConnections > 1000000 { throw ContractValidationError(field: "active_connections", code: "maximum") }
|
if self.activeConnections > 1000000 { throw ContractValidationError(field: "active_connections", code: "maximum") }
|
||||||
if self.egressKbps < 0 { throw ContractValidationError(field: "egress_kbps", code: "minimum") }
|
if self.egressKbps < 0 { throw ContractValidationError(field: "egress_kbps", code: "minimum") }
|
||||||
@@ -1136,6 +1304,8 @@ public struct GatewayRegistration: Codable, Equatable {
|
|||||||
if self.bandwidthCapacityKbps < 1 { throw ContractValidationError(field: "bandwidth_capacity_kbps", code: "minimum") }
|
if self.bandwidthCapacityKbps < 1 { throw ContractValidationError(field: "bandwidth_capacity_kbps", code: "minimum") }
|
||||||
if self.bandwidthCapacityKbps > 1000000000 { throw ContractValidationError(field: "bandwidth_capacity_kbps", code: "maximum") }
|
if self.bandwidthCapacityKbps > 1000000000 { throw ContractValidationError(field: "bandwidth_capacity_kbps", code: "maximum") }
|
||||||
if self.features.count > 64 { throw ContractValidationError(field: "features", code: "max_items") }
|
if self.features.count > 64 { throw ContractValidationError(field: "features", code: "max_items") }
|
||||||
|
for item in self.features where item.utf8.count < 1 { throw ContractValidationError(field: "features", code: "min_item_length") }
|
||||||
|
for item in self.features where item.utf8.count > 64 { throw ContractValidationError(field: "features", code: "max_item_length") }
|
||||||
try self.capabilities.validate()
|
try self.capabilities.validate()
|
||||||
if protocolMinVersion > protocolMaxVersion { throw ContractValidationError(field: "protocol_version", code: "invalid_order") }
|
if protocolMinVersion > protocolMaxVersion { throw ContractValidationError(field: "protocol_version", code: "invalid_order") }
|
||||||
}
|
}
|
||||||
@@ -1284,7 +1454,7 @@ public struct GrantReference: Codable, Equatable {
|
|||||||
if !self.opaqueValue.isEmpty && self.opaqueValue.utf8.count < 43 { throw ContractValidationError(field: "opaque_value", code: "min_length") }
|
if !self.opaqueValue.isEmpty && self.opaqueValue.utf8.count < 43 { throw ContractValidationError(field: "opaque_value", code: "min_length") }
|
||||||
if self.opaqueValue.utf8.count > 256 { throw ContractValidationError(field: "opaque_value", code: "max_length") }
|
if self.opaqueValue.utf8.count > 256 { throw ContractValidationError(field: "opaque_value", code: "max_length") }
|
||||||
if self.expiresAt.utf8.count > 64 { throw ContractValidationError(field: "expires_at", code: "max_length") }
|
if self.expiresAt.utf8.count > 64 { throw ContractValidationError(field: "expires_at", code: "max_length") }
|
||||||
if ISO8601DateFormatter().date(from: self.expiresAt) == nil { throw ContractValidationError(field: "expires_at", code: "invalid_time") }
|
if !validRFC3339UTC(self.expiresAt) { throw ContractValidationError(field: "expires_at", code: "invalid_time") }
|
||||||
if self.audience.isEmpty { throw ContractValidationError(field: "audience", code: "required") }
|
if self.audience.isEmpty { throw ContractValidationError(field: "audience", code: "required") }
|
||||||
if !self.audience.isEmpty && self.audience.utf8.count < 1 { throw ContractValidationError(field: "audience", code: "min_length") }
|
if !self.audience.isEmpty && self.audience.utf8.count < 1 { throw ContractValidationError(field: "audience", code: "min_length") }
|
||||||
if self.audience.utf8.count > 128 { throw ContractValidationError(field: "audience", code: "max_length") }
|
if self.audience.utf8.count > 128 { throw ContractValidationError(field: "audience", code: "max_length") }
|
||||||
@@ -1402,6 +1572,8 @@ public struct ManifestGateway: Codable, Equatable {
|
|||||||
if self.id.utf8.count > 128 { throw ContractValidationError(field: "id", code: "max_length") }
|
if self.id.utf8.count > 128 { throw ContractValidationError(field: "id", code: "max_length") }
|
||||||
if self.addresses.count < 1 { throw ContractValidationError(field: "addresses", code: "min_items") }
|
if self.addresses.count < 1 { throw ContractValidationError(field: "addresses", code: "min_items") }
|
||||||
if self.addresses.count > 4 { throw ContractValidationError(field: "addresses", code: "max_items") }
|
if self.addresses.count > 4 { throw ContractValidationError(field: "addresses", code: "max_items") }
|
||||||
|
for item in self.addresses where item.utf8.count < 1 { throw ContractValidationError(field: "addresses", code: "min_item_length") }
|
||||||
|
for item in self.addresses where item.utf8.count > 256 { throw ContractValidationError(field: "addresses", code: "max_item_length") }
|
||||||
if self.publicIdentity.isEmpty { throw ContractValidationError(field: "public_identity", code: "required") }
|
if self.publicIdentity.isEmpty { throw ContractValidationError(field: "public_identity", code: "required") }
|
||||||
if !self.publicIdentity.isEmpty && self.publicIdentity.utf8.count < 1 { throw ContractValidationError(field: "public_identity", code: "min_length") }
|
if !self.publicIdentity.isEmpty && self.publicIdentity.utf8.count < 1 { throw ContractValidationError(field: "public_identity", code: "min_length") }
|
||||||
if self.publicIdentity.utf8.count > 256 { throw ContractValidationError(field: "public_identity", code: "max_length") }
|
if self.publicIdentity.utf8.count > 256 { throw ContractValidationError(field: "public_identity", code: "max_length") }
|
||||||
@@ -1414,14 +1586,17 @@ public struct ManifestGateway: Codable, Equatable {
|
|||||||
public struct ManifestProfile: Codable, Equatable {
|
public struct ManifestProfile: Codable, Equatable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let bounds: ManifestBounds
|
public let bounds: ManifestBounds
|
||||||
|
public let displayMode: DisplayMode?
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id = "id"
|
case id = "id"
|
||||||
case bounds = "bounds"
|
case bounds = "bounds"
|
||||||
|
case displayMode = "display_mode"
|
||||||
}
|
}
|
||||||
|
|
||||||
public init(id: String, bounds: ManifestBounds) throws {
|
public init(id: String, bounds: ManifestBounds, displayMode: DisplayMode?) throws {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.bounds = bounds
|
self.bounds = bounds
|
||||||
|
self.displayMode = displayMode
|
||||||
try validate()
|
try validate()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1429,7 +1604,7 @@ public struct ManifestProfile: Codable, Equatable {
|
|||||||
let all = try decoder.container(keyedBy: AnyCodingKey.self)
|
let all = try decoder.container(keyedBy: AnyCodingKey.self)
|
||||||
for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") }
|
for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") }
|
||||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
try self.init(id: try c.decode(String.self, forKey: .id), bounds: try c.decode(ManifestBounds.self, forKey: .bounds))
|
try self.init(id: try c.decode(String.self, forKey: .id), bounds: try c.decode(ManifestBounds.self, forKey: .bounds), displayMode: try c.contains(.displayMode) ? c.decode(DisplayMode.self, forKey: .displayMode) : nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func validate() throws {
|
public func validate() throws {
|
||||||
@@ -1437,6 +1612,9 @@ public struct ManifestProfile: Codable, Equatable {
|
|||||||
if !self.id.isEmpty && self.id.utf8.count < 1 { throw ContractValidationError(field: "id", code: "min_length") }
|
if !self.id.isEmpty && self.id.utf8.count < 1 { throw ContractValidationError(field: "id", code: "min_length") }
|
||||||
if self.id.utf8.count > 128 { throw ContractValidationError(field: "id", code: "max_length") }
|
if self.id.utf8.count > 128 { throw ContractValidationError(field: "id", code: "max_length") }
|
||||||
try self.bounds.validate()
|
try self.bounds.validate()
|
||||||
|
if let value = self.displayMode {
|
||||||
|
try value.validate()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }
|
public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }
|
||||||
@@ -1467,7 +1645,60 @@ public struct ManifestTunnel: Codable, Equatable {
|
|||||||
public func validate() throws {
|
public func validate() throws {
|
||||||
if self.versions.count < 1 { throw ContractValidationError(field: "versions", code: "min_items") }
|
if self.versions.count < 1 { throw ContractValidationError(field: "versions", code: "min_items") }
|
||||||
if self.versions.count > 4 { throw ContractValidationError(field: "versions", code: "max_items") }
|
if self.versions.count > 4 { throw ContractValidationError(field: "versions", code: "max_items") }
|
||||||
|
for item in self.versions where item.utf8.count < 1 { throw ContractValidationError(field: "versions", code: "min_item_length") }
|
||||||
|
for item in self.versions where item.utf8.count > 64 { throw ContractValidationError(field: "versions", code: "max_item_length") }
|
||||||
if self.features.count > 32 { throw ContractValidationError(field: "features", code: "max_items") }
|
if self.features.count > 32 { throw ContractValidationError(field: "features", code: "max_items") }
|
||||||
|
for item in self.features where item.utf8.count < 1 { throw ContractValidationError(field: "features", code: "min_item_length") }
|
||||||
|
for item in self.features where item.utf8.count > 64 { throw ContractValidationError(field: "features", code: "max_item_length") }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }
|
||||||
|
public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct NativeAuthenticatedSession: Codable, Equatable {
|
||||||
|
public let username: String
|
||||||
|
public let provider: String
|
||||||
|
public let roles: [String]
|
||||||
|
public let role: String
|
||||||
|
public let nativeIdentity: NativeSessionIdentity
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case username = "username"
|
||||||
|
case provider = "provider"
|
||||||
|
case roles = "roles"
|
||||||
|
case role = "role"
|
||||||
|
case nativeIdentity = "native_identity"
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(username: String, provider: String, roles: [String], role: String, nativeIdentity: NativeSessionIdentity) throws {
|
||||||
|
self.username = username
|
||||||
|
self.provider = provider
|
||||||
|
self.roles = roles
|
||||||
|
self.role = role
|
||||||
|
self.nativeIdentity = nativeIdentity
|
||||||
|
try validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let all = try decoder.container(keyedBy: AnyCodingKey.self)
|
||||||
|
for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") }
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try self.init(username: try c.decode(String.self, forKey: .username), provider: try c.decode(String.self, forKey: .provider), roles: try c.decode([String].self, forKey: .roles), role: try c.decode(String.self, forKey: .role), nativeIdentity: try c.decode(NativeSessionIdentity.self, forKey: .nativeIdentity))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func validate() throws {
|
||||||
|
if self.username.isEmpty { throw ContractValidationError(field: "username", code: "required") }
|
||||||
|
if !self.username.isEmpty && self.username.utf8.count < 1 { throw ContractValidationError(field: "username", code: "min_length") }
|
||||||
|
if self.username.utf8.count > 256 { throw ContractValidationError(field: "username", code: "max_length") }
|
||||||
|
if self.provider.isEmpty { throw ContractValidationError(field: "provider", code: "required") }
|
||||||
|
if !self.provider.isEmpty && self.provider.utf8.count < 1 { throw ContractValidationError(field: "provider", code: "min_length") }
|
||||||
|
if self.provider.utf8.count > 64 { throw ContractValidationError(field: "provider", code: "max_length") }
|
||||||
|
if self.roles.count > 16 { throw ContractValidationError(field: "roles", code: "max_items") }
|
||||||
|
for item in self.roles where item.utf8.count < 1 { throw ContractValidationError(field: "roles", code: "min_item_length") }
|
||||||
|
for item in self.roles where item.utf8.count > 64 { throw ContractValidationError(field: "roles", code: "max_item_length") }
|
||||||
|
for item in self.roles where item.utf8.count > 64 { throw ContractValidationError(field: "roles", code: "max_item_bytes") }
|
||||||
|
if !["user", "admin"].contains(self.role) { throw ContractValidationError(field: "role", code: "invalid_value") }
|
||||||
|
try self.nativeIdentity.validate()
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }
|
public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }
|
||||||
@@ -1521,10 +1752,10 @@ public struct NativeCredential: Codable, Equatable {
|
|||||||
if !self.refreshToken.isEmpty && self.refreshToken.utf8.count < 1 { throw ContractValidationError(field: "refresh_token", code: "min_length") }
|
if !self.refreshToken.isEmpty && self.refreshToken.utf8.count < 1 { throw ContractValidationError(field: "refresh_token", code: "min_length") }
|
||||||
if self.refreshToken.utf8.count > 256 { throw ContractValidationError(field: "refresh_token", code: "max_length") }
|
if self.refreshToken.utf8.count > 256 { throw ContractValidationError(field: "refresh_token", code: "max_length") }
|
||||||
if self.expiresAt.utf8.count > 64 { throw ContractValidationError(field: "expires_at", code: "max_length") }
|
if self.expiresAt.utf8.count > 64 { throw ContractValidationError(field: "expires_at", code: "max_length") }
|
||||||
if ISO8601DateFormatter().date(from: self.expiresAt) == nil { throw ContractValidationError(field: "expires_at", code: "invalid_time") }
|
if !validRFC3339UTC(self.expiresAt) { throw ContractValidationError(field: "expires_at", code: "invalid_time") }
|
||||||
if let value = self.refreshExpiresAt {
|
if let value = self.refreshExpiresAt {
|
||||||
if value.utf8.count > 64 { throw ContractValidationError(field: "refresh_expires_at", code: "max_length") }
|
if value.utf8.count > 64 { throw ContractValidationError(field: "refresh_expires_at", code: "max_length") }
|
||||||
if ISO8601DateFormatter().date(from: value) == nil { throw ContractValidationError(field: "refresh_expires_at", code: "invalid_time") }
|
if !validRFC3339UTC(value) { throw ContractValidationError(field: "refresh_expires_at", code: "invalid_time") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1532,6 +1763,91 @@ public struct NativeCredential: Codable, Equatable {
|
|||||||
public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }
|
public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct NativeSessionIdentity: Codable, Equatable {
|
||||||
|
public let clientDeviceId: String
|
||||||
|
public let deviceKeyId: String
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case clientDeviceId = "client_device_id"
|
||||||
|
case deviceKeyId = "device_key_id"
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(clientDeviceId: String, deviceKeyId: String) throws {
|
||||||
|
self.clientDeviceId = clientDeviceId
|
||||||
|
self.deviceKeyId = deviceKeyId
|
||||||
|
try validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let all = try decoder.container(keyedBy: AnyCodingKey.self)
|
||||||
|
for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") }
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try self.init(clientDeviceId: try c.decode(String.self, forKey: .clientDeviceId), deviceKeyId: try c.decode(String.self, forKey: .deviceKeyId))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func validate() throws {
|
||||||
|
if self.clientDeviceId.isEmpty { throw ContractValidationError(field: "client_device_id", code: "required") }
|
||||||
|
if !self.clientDeviceId.isEmpty && self.clientDeviceId.utf8.count < 1 { throw ContractValidationError(field: "client_device_id", code: "min_length") }
|
||||||
|
if self.clientDeviceId.utf8.count > 128 { throw ContractValidationError(field: "client_device_id", code: "max_length") }
|
||||||
|
if self.deviceKeyId.isEmpty { throw ContractValidationError(field: "device_key_id", code: "required") }
|
||||||
|
if !self.deviceKeyId.isEmpty && self.deviceKeyId.utf8.count < 1 { throw ContractValidationError(field: "device_key_id", code: "min_length") }
|
||||||
|
if self.deviceKeyId.utf8.count > 128 { throw ContractValidationError(field: "device_key_id", code: "max_length") }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }
|
||||||
|
public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct NativeTunnelCredential: Codable, Equatable {
|
||||||
|
public let clientDeviceId: String
|
||||||
|
public let deviceKeyId: String
|
||||||
|
public let certificateChainPem: String
|
||||||
|
public let trustBundlePem: String
|
||||||
|
public let expiresAt: String
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case clientDeviceId = "client_device_id"
|
||||||
|
case deviceKeyId = "device_key_id"
|
||||||
|
case certificateChainPem = "certificate_chain_pem"
|
||||||
|
case trustBundlePem = "trust_bundle_pem"
|
||||||
|
case expiresAt = "expires_at"
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(clientDeviceId: String, deviceKeyId: String, certificateChainPem: String, trustBundlePem: String, expiresAt: String) throws {
|
||||||
|
self.clientDeviceId = clientDeviceId
|
||||||
|
self.deviceKeyId = deviceKeyId
|
||||||
|
self.certificateChainPem = certificateChainPem
|
||||||
|
self.trustBundlePem = trustBundlePem
|
||||||
|
self.expiresAt = expiresAt
|
||||||
|
try validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let all = try decoder.container(keyedBy: AnyCodingKey.self)
|
||||||
|
for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") }
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try self.init(clientDeviceId: try c.decode(String.self, forKey: .clientDeviceId), deviceKeyId: try c.decode(String.self, forKey: .deviceKeyId), certificateChainPem: try c.decode(String.self, forKey: .certificateChainPem), trustBundlePem: try c.decode(String.self, forKey: .trustBundlePem), expiresAt: try c.decode(String.self, forKey: .expiresAt))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func validate() throws {
|
||||||
|
if self.clientDeviceId.isEmpty { throw ContractValidationError(field: "client_device_id", code: "required") }
|
||||||
|
if !self.clientDeviceId.isEmpty && self.clientDeviceId.utf8.count < 1 { throw ContractValidationError(field: "client_device_id", code: "min_length") }
|
||||||
|
if self.clientDeviceId.utf8.count > 128 { throw ContractValidationError(field: "client_device_id", code: "max_length") }
|
||||||
|
if self.deviceKeyId.isEmpty { throw ContractValidationError(field: "device_key_id", code: "required") }
|
||||||
|
if !self.deviceKeyId.isEmpty && self.deviceKeyId.utf8.count < 1 { throw ContractValidationError(field: "device_key_id", code: "min_length") }
|
||||||
|
if self.deviceKeyId.utf8.count > 128 { throw ContractValidationError(field: "device_key_id", code: "max_length") }
|
||||||
|
if self.certificateChainPem.isEmpty { throw ContractValidationError(field: "certificate_chain_pem", code: "required") }
|
||||||
|
if !self.certificateChainPem.isEmpty && self.certificateChainPem.utf8.count < 1 { throw ContractValidationError(field: "certificate_chain_pem", code: "min_length") }
|
||||||
|
if self.certificateChainPem.utf8.count > 65536 { throw ContractValidationError(field: "certificate_chain_pem", code: "max_length") }
|
||||||
|
if self.trustBundlePem.isEmpty { throw ContractValidationError(field: "trust_bundle_pem", code: "required") }
|
||||||
|
if !self.trustBundlePem.isEmpty && self.trustBundlePem.utf8.count < 1 { throw ContractValidationError(field: "trust_bundle_pem", code: "min_length") }
|
||||||
|
if self.trustBundlePem.utf8.count > 65536 { throw ContractValidationError(field: "trust_bundle_pem", code: "max_length") }
|
||||||
|
if self.expiresAt.utf8.count > 64 { throw ContractValidationError(field: "expires_at", code: "max_length") }
|
||||||
|
if !validRFC3339UTC(self.expiresAt) { throw ContractValidationError(field: "expires_at", code: "invalid_time") }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }
|
||||||
|
public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }
|
||||||
|
}
|
||||||
|
|
||||||
public struct PageInfo: Codable, Equatable {
|
public struct PageInfo: Codable, Equatable {
|
||||||
public let limit: Int64
|
public let limit: Int64
|
||||||
public let nextCursor: String
|
public let nextCursor: String
|
||||||
@@ -1648,7 +1964,7 @@ public struct ProviderSessionWork: Codable, Equatable {
|
|||||||
if self.gatewayId.utf8.count > 128 { throw ContractValidationError(field: "gateway_id", code: "max_length") }
|
if self.gatewayId.utf8.count > 128 { throw ContractValidationError(field: "gateway_id", code: "max_length") }
|
||||||
if self.reconnectSequence < 0 { throw ContractValidationError(field: "reconnect_sequence", code: "minimum") }
|
if self.reconnectSequence < 0 { throw ContractValidationError(field: "reconnect_sequence", code: "minimum") }
|
||||||
if self.expiresAt.utf8.count > 64 { throw ContractValidationError(field: "expires_at", code: "max_length") }
|
if self.expiresAt.utf8.count > 64 { throw ContractValidationError(field: "expires_at", code: "max_length") }
|
||||||
if ISO8601DateFormatter().date(from: self.expiresAt) == nil { throw ContractValidationError(field: "expires_at", code: "invalid_time") }
|
if !validRFC3339UTC(self.expiresAt) { throw ContractValidationError(field: "expires_at", code: "invalid_time") }
|
||||||
if self.providerProfile != "apollo" { throw ContractValidationError(field: "provider_profile", code: "invalid_value") }
|
if self.providerProfile != "apollo" { throw ContractValidationError(field: "provider_profile", code: "invalid_value") }
|
||||||
if self.providerIdentity.isEmpty { throw ContractValidationError(field: "provider_identity", code: "required") }
|
if self.providerIdentity.isEmpty { throw ContractValidationError(field: "provider_identity", code: "required") }
|
||||||
if !self.providerIdentity.isEmpty && self.providerIdentity.utf8.count < 1 { throw ContractValidationError(field: "provider_identity", code: "min_length") }
|
if !self.providerIdentity.isEmpty && self.providerIdentity.utf8.count < 1 { throw ContractValidationError(field: "provider_identity", code: "min_length") }
|
||||||
@@ -1726,6 +2042,8 @@ public struct ProviderState: Codable, Equatable {
|
|||||||
if self.sessionId.utf8.count > 128 { throw ContractValidationError(field: "session_id", code: "max_length") }
|
if self.sessionId.utf8.count > 128 { throw ContractValidationError(field: "session_id", code: "max_length") }
|
||||||
if !["starting", "ready", "disconnected", "terminating", "terminated", "cleanup_pending", "failed"].contains(self.state) { throw ContractValidationError(field: "state", code: "invalid_value") }
|
if !["starting", "ready", "disconnected", "terminating", "terminated", "cleanup_pending", "failed"].contains(self.state) { throw ContractValidationError(field: "state", code: "invalid_value") }
|
||||||
if self.channels.count > 8 { throw ContractValidationError(field: "channels", code: "max_items") }
|
if self.channels.count > 8 { throw ContractValidationError(field: "channels", code: "max_items") }
|
||||||
|
for item in self.channels where item.utf8.count < 1 { throw ContractValidationError(field: "channels", code: "min_item_length") }
|
||||||
|
for item in self.channels where item.utf8.count > 64 { throw ContractValidationError(field: "channels", code: "max_item_length") }
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }
|
public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }
|
||||||
@@ -1813,7 +2131,7 @@ public struct ReauthGrant: Codable, Equatable {
|
|||||||
if !self.purpose.isEmpty && self.purpose.utf8.count < 1 { throw ContractValidationError(field: "purpose", code: "min_length") }
|
if !self.purpose.isEmpty && self.purpose.utf8.count < 1 { throw ContractValidationError(field: "purpose", code: "min_length") }
|
||||||
if self.purpose.utf8.count > 64 { throw ContractValidationError(field: "purpose", code: "max_length") }
|
if self.purpose.utf8.count > 64 { throw ContractValidationError(field: "purpose", code: "max_length") }
|
||||||
if self.expiresAt.utf8.count > 64 { throw ContractValidationError(field: "expires_at", code: "max_length") }
|
if self.expiresAt.utf8.count > 64 { throw ContractValidationError(field: "expires_at", code: "max_length") }
|
||||||
if ISO8601DateFormatter().date(from: self.expiresAt) == nil { throw ContractValidationError(field: "expires_at", code: "invalid_time") }
|
if !validRFC3339UTC(self.expiresAt) { throw ContractValidationError(field: "expires_at", code: "invalid_time") }
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }
|
public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }
|
||||||
@@ -2114,7 +2432,7 @@ public struct SessionAuthority: Codable, Equatable {
|
|||||||
if self.audience.utf8.count > 256 { throw ContractValidationError(field: "audience", code: "max_length") }
|
if self.audience.utf8.count > 256 { throw ContractValidationError(field: "audience", code: "max_length") }
|
||||||
if self.reconnectSequence < 0 { throw ContractValidationError(field: "reconnect_sequence", code: "minimum") }
|
if self.reconnectSequence < 0 { throw ContractValidationError(field: "reconnect_sequence", code: "minimum") }
|
||||||
if self.expiresAt.utf8.count > 64 { throw ContractValidationError(field: "expires_at", code: "max_length") }
|
if self.expiresAt.utf8.count > 64 { throw ContractValidationError(field: "expires_at", code: "max_length") }
|
||||||
if ISO8601DateFormatter().date(from: self.expiresAt) == nil { throw ContractValidationError(field: "expires_at", code: "invalid_time") }
|
if !validRFC3339UTC(self.expiresAt) { throw ContractValidationError(field: "expires_at", code: "invalid_time") }
|
||||||
try self.capabilities.validate()
|
try self.capabilities.validate()
|
||||||
if !["apollo"].contains(self.providerProfile) { throw ContractValidationError(field: "provider_profile", code: "invalid_value") }
|
if !["apollo"].contains(self.providerProfile) { throw ContractValidationError(field: "provider_profile", code: "invalid_value") }
|
||||||
if self.providerIdentity.isEmpty { throw ContractValidationError(field: "provider_identity", code: "required") }
|
if self.providerIdentity.isEmpty { throw ContractValidationError(field: "provider_identity", code: "required") }
|
||||||
@@ -2131,21 +2449,21 @@ public struct SessionRequest: Codable, Equatable {
|
|||||||
public let deviceKeyId: String
|
public let deviceKeyId: String
|
||||||
public let poolId: String
|
public let poolId: String
|
||||||
public let idempotencyKey: String
|
public let idempotencyKey: String
|
||||||
public let policySnapshot: AllocationPolicy
|
public let requestedDisplayMode: DisplayMode?
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case clientDeviceId = "client_device_id"
|
case clientDeviceId = "client_device_id"
|
||||||
case deviceKeyId = "device_key_id"
|
case deviceKeyId = "device_key_id"
|
||||||
case poolId = "pool_id"
|
case poolId = "pool_id"
|
||||||
case idempotencyKey = "idempotency_key"
|
case idempotencyKey = "idempotency_key"
|
||||||
case policySnapshot = "policy_snapshot"
|
case requestedDisplayMode = "requested_display_mode"
|
||||||
}
|
}
|
||||||
|
|
||||||
public init(clientDeviceId: String, deviceKeyId: String, poolId: String, idempotencyKey: String, policySnapshot: AllocationPolicy) throws {
|
public init(clientDeviceId: String, deviceKeyId: String, poolId: String, idempotencyKey: String, requestedDisplayMode: DisplayMode?) throws {
|
||||||
self.clientDeviceId = clientDeviceId
|
self.clientDeviceId = clientDeviceId
|
||||||
self.deviceKeyId = deviceKeyId
|
self.deviceKeyId = deviceKeyId
|
||||||
self.poolId = poolId
|
self.poolId = poolId
|
||||||
self.idempotencyKey = idempotencyKey
|
self.idempotencyKey = idempotencyKey
|
||||||
self.policySnapshot = policySnapshot
|
self.requestedDisplayMode = requestedDisplayMode
|
||||||
try validate()
|
try validate()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2153,7 +2471,7 @@ public struct SessionRequest: Codable, Equatable {
|
|||||||
let all = try decoder.container(keyedBy: AnyCodingKey.self)
|
let all = try decoder.container(keyedBy: AnyCodingKey.self)
|
||||||
for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") }
|
for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") }
|
||||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
try self.init(clientDeviceId: try c.decode(String.self, forKey: .clientDeviceId), deviceKeyId: try c.decode(String.self, forKey: .deviceKeyId), poolId: try c.decode(String.self, forKey: .poolId), idempotencyKey: try c.decode(String.self, forKey: .idempotencyKey), policySnapshot: try c.decode(AllocationPolicy.self, forKey: .policySnapshot))
|
try self.init(clientDeviceId: try c.decode(String.self, forKey: .clientDeviceId), deviceKeyId: try c.decode(String.self, forKey: .deviceKeyId), poolId: try c.decode(String.self, forKey: .poolId), idempotencyKey: try c.decode(String.self, forKey: .idempotencyKey), requestedDisplayMode: try c.contains(.requestedDisplayMode) ? c.decode(DisplayMode.self, forKey: .requestedDisplayMode) : nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func validate() throws {
|
public func validate() throws {
|
||||||
@@ -2169,7 +2487,9 @@ public struct SessionRequest: Codable, Equatable {
|
|||||||
if self.idempotencyKey.isEmpty { throw ContractValidationError(field: "idempotency_key", code: "required") }
|
if self.idempotencyKey.isEmpty { throw ContractValidationError(field: "idempotency_key", code: "required") }
|
||||||
if !self.idempotencyKey.isEmpty && self.idempotencyKey.utf8.count < 1 { throw ContractValidationError(field: "idempotency_key", code: "min_length") }
|
if !self.idempotencyKey.isEmpty && self.idempotencyKey.utf8.count < 1 { throw ContractValidationError(field: "idempotency_key", code: "min_length") }
|
||||||
if self.idempotencyKey.utf8.count > 256 { throw ContractValidationError(field: "idempotency_key", code: "max_length") }
|
if self.idempotencyKey.utf8.count > 256 { throw ContractValidationError(field: "idempotency_key", code: "max_length") }
|
||||||
try self.policySnapshot.validate()
|
if let value = self.requestedDisplayMode {
|
||||||
|
try value.validate()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }
|
public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }
|
||||||
@@ -2311,13 +2631,30 @@ public struct VersionNegotiation: Codable, Equatable {
|
|||||||
public func validate() throws {
|
public func validate() throws {
|
||||||
if self.supportedVersions.count < 1 { throw ContractValidationError(field: "supported_versions", code: "min_items") }
|
if self.supportedVersions.count < 1 { throw ContractValidationError(field: "supported_versions", code: "min_items") }
|
||||||
if self.supportedVersions.count > 3 { throw ContractValidationError(field: "supported_versions", code: "max_items") }
|
if self.supportedVersions.count > 3 { throw ContractValidationError(field: "supported_versions", code: "max_items") }
|
||||||
|
for item in self.supportedVersions where item.utf8.count > 16 { throw ContractValidationError(field: "supported_versions", code: "max_item_length") }
|
||||||
if self.features.count > 64 { throw ContractValidationError(field: "features", code: "max_items") }
|
if self.features.count > 64 { throw ContractValidationError(field: "features", code: "max_items") }
|
||||||
|
for item in self.features where item.utf8.count > 64 { throw ContractValidationError(field: "features", code: "max_item_length") }
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }
|
public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }
|
||||||
public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }
|
public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
public extension TunnelAdmissionRequest {
|
||||||
func deviceAdmissionTranscript() -> Data {
|
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)]
|
var fields = [sessionId, gatewayId, audience, grant, String(reconnectSequence), clientNonce, capabilities.transport, capabilities.framing, capabilities.media, capabilities.audio, capabilities.sourceRateControl, String(capabilities.clientDecode.count)]
|
||||||
|
|||||||
+55
-19
@@ -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
|
||||||
|
|||||||
+3
-1
@@ -15,4 +15,6 @@
|
|||||||
|
|
||||||
## 4. Immutable boundary
|
## 4. Immutable boundary
|
||||||
|
|
||||||
- [ ] 4.1 Publish one new never-reused immutable Protocol version and resolve it from empty consumer caches
|
- [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.
|
||||||
+33
@@ -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.
|
||||||
+35
@@ -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.
|
||||||
+30
@@ -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.
|
||||||
@@ -7,20 +7,32 @@ used across the authenticated Verse gateway boundary.
|
|||||||
### Requirement: Typed sequenced input envelope
|
### Requirement: Typed sequenced input envelope
|
||||||
The `input.sequenced.v1` payload SHALL begin with ASCII `VGI1`, a one-byte
|
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
|
event kind, and one-byte payload length. It SHALL contain exactly one bounded
|
||||||
keyboard, mouse-button, relative-mouse, UTF-8 scalar, or controller-state
|
keyboard, mouse-button, relative-mouse, UTF-8 scalar, controller-state,
|
||||||
event. False keyboard/mouse state and zeroed controller state are explicit
|
absolute-mouse, or high-resolution-scroll event. Absolute mouse kind `0x06`
|
||||||
releases. Multibyte integer fields SHALL be big-endian. Unknown kinds,
|
SHALL contain big-endian u16 x, y, viewport width, and viewport height, reject
|
||||||
length mismatches, malformed UTF-8, unsupported controller indices, and
|
zero viewports and coordinates outside the viewport, and require negotiated
|
||||||
reserved fields SHALL be rejected before provider translation.
|
`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
|
#### Scenario: Keyboard state change
|
||||||
- **WHEN** a client sends a valid keyboard press or release envelope
|
- **WHEN** a client sends a valid keyboard press or release envelope
|
||||||
- **THEN** the gateway forwards the corresponding typed provider input on its
|
- **THEN** the gateway forwards the corresponding typed provider input on its
|
||||||
reliable keyboard channel and records the pressed state for cleanup.
|
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
|
#### Scenario: Invalid input envelope
|
||||||
- **WHEN** a client sends an envelope with an unknown event kind, invalid
|
- **WHEN** a client sends an unknown or unadvertised kind, invalid length,
|
||||||
length, malformed UTF-8 scalar, or nonzero reserved field
|
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
|
- **THEN** the gateway rejects it without sending provider input or changing
|
||||||
pressed state.
|
pressed state.
|
||||||
|
|
||||||
|
|||||||
@@ -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,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.
|
||||||
@@ -117,6 +117,16 @@ message SessionAuthority {
|
|||||||
string provider_identity = 9;
|
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 {
|
message ProviderSessionWork {
|
||||||
string version = 1;
|
string version = 1;
|
||||||
string session_id = 2;
|
string session_id = 2;
|
||||||
|
|||||||
@@ -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}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": {
|
||||||
@@ -489,6 +551,20 @@
|
|||||||
"provider_identity": {"type": "string", "minLength": 1, "maxLength": 256}
|
"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", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$", "maxLength": 64},
|
||||||
|
"capabilities": {"$ref": "#/$defs/CapabilityProfile"}
|
||||||
|
}
|
||||||
|
},
|
||||||
"ProviderStreamPolicy": {
|
"ProviderStreamPolicy": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
|||||||
+263
-1
@@ -1,6 +1,9 @@
|
|||||||
package protocol_test
|
package protocol_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -8,8 +11,47 @@ import (
|
|||||||
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)
|
||||||
@@ -22,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")
|
||||||
@@ -176,6 +363,81 @@ func TestSessionAuthorityRejectsProviderRoute(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
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}`
|
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 {
|
if _, err := protocol.DecodeProviderSessionWork([]byte(valid)); err != nil {
|
||||||
|
|||||||
+114
-3
@@ -150,6 +150,12 @@ def go_validation(definition: dict[str, Any]) -> list[str]:
|
|||||||
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\"}}) }}")
|
||||||
items = 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:
|
if "enum" in items:
|
||||||
allowed = " || ".join(f'item == "{value}"' for value in items["enum"])
|
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\"}}) }} }}")
|
lines.append(f"\tfor _, item := range v.{field} {{ if !({allowed}) {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"invalid_item\"}}) }} }}")
|
||||||
@@ -160,7 +166,11 @@ def go_validation(definition: dict[str, Any]) -> list[str]:
|
|||||||
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"}:
|
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\"}) }")
|
lines.append("\tif v.MinimumKbps > v.TargetKbps || v.TargetKbps > v.MaximumKbps { violations = append(violations, FieldViolation{Field: \"bounds\", Code: \"invalid_order\"}) }")
|
||||||
if name == "GatewayRegistration":
|
if name == "GatewayRegistration":
|
||||||
@@ -177,6 +187,7 @@ def generate_go(defs: dict[str, dict[str, Any]], schema_hash: str, version: str,
|
|||||||
"",
|
"",
|
||||||
"import (",
|
"import (",
|
||||||
"\"bytes\"",
|
"\"bytes\"",
|
||||||
|
"\"encoding/binary\"",
|
||||||
"\"encoding/base64\"",
|
"\"encoding/base64\"",
|
||||||
"\"encoding/json\"",
|
"\"encoding/json\"",
|
||||||
"\"errors\"",
|
"\"errors\"",
|
||||||
@@ -212,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 {{")
|
||||||
@@ -234,6 +248,11 @@ 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 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":
|
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"}}} }'
|
||||||
@@ -254,6 +273,22 @@ def generate_go(defs: dict[str, dict[str, Any]], schema_hash: str, version: str,
|
|||||||
out.append("}")
|
out.append("}")
|
||||||
out.append("")
|
out.append("")
|
||||||
out.extend([
|
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\")",
|
"var ErrNoCapabilityOverlap = errors.New(\"no capability overlap\")",
|
||||||
"",
|
"",
|
||||||
"func IntersectCapabilityProfiles(profiles ...CapabilityProfile) (CapabilityProfile, error) {",
|
"func IntersectCapabilityProfiles(profiles ...CapabilityProfile) (CapabilityProfile, error) {",
|
||||||
@@ -347,6 +382,8 @@ def rust_validation(definition: dict[str, Any]) -> list[str]:
|
|||||||
if "enum" in prop:
|
if "enum" in prop:
|
||||||
allowed = " && ".join(f'{value} != \"{item}\"' for item in prop["enum"])
|
allowed = " && ".join(f'{value} != \"{item}\"' for item in prop["enum"])
|
||||||
lines.append(f" {prefix}if {allowed} {{ return Err(ValidationError::new(\"{prop_name}\", \"invalid_value\")); }}")
|
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":
|
if prop.get("format") == "base64url":
|
||||||
lines.append(f" {prefix}if !valid_base64_url({value}.as_str()) {{ return Err(ValidationError::new(\"{prop_name}\", \"invalid_format\")); }}")
|
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 prop.get("type") == "integer":
|
||||||
@@ -360,6 +397,12 @@ def rust_validation(definition: dict[str, Any]) -> list[str]:
|
|||||||
if "maxItems" in prop:
|
if "maxItems" in prop:
|
||||||
lines.append(f" {prefix}if {value}.len() > {prop['maxItems']} {{ return Err(ValidationError::new(\"{prop_name}\", \"max_items\")); }}")
|
lines.append(f" {prefix}if {value}.len() > {prop['maxItems']} {{ return Err(ValidationError::new(\"{prop_name}\", \"max_items\")); }}")
|
||||||
items = prop.get("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:
|
if "enum" in items:
|
||||||
allowed = " && ".join(f'item != \"{item}\"' for item in items["enum"])
|
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\")); }} }}")
|
lines.append(f" {prefix}for item in {value}.iter() {{ if {allowed} {{ return Err(ValidationError::new(\"{prop_name}\", \"invalid_item\")); }} }}")
|
||||||
@@ -417,6 +460,19 @@ def generate_rust(defs: dict[str, dict[str, Any]], schema_hash: str, compatibili
|
|||||||
" _ => false,",
|
" _ => 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):
|
||||||
@@ -469,6 +525,21 @@ def generate_rust(defs: dict[str, dict[str, Any]], schema_hash: str, compatibili
|
|||||||
])
|
])
|
||||||
out.extend(["}", ""])
|
out.extend(["}", ""])
|
||||||
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> {",
|
"pub fn intersect_capability_profiles(profiles: &[CapabilityProfile]) -> Result<CapabilityProfile, ValidationError> {",
|
||||||
" let mut selected = profiles.first().ok_or_else(|| ValidationError::new(\"capabilities\", \"no_overlap\"))?.clone();",
|
" let mut selected = profiles.first().ok_or_else(|| ValidationError::new(\"capabilities\", \"no_overlap\"))?.clone();",
|
||||||
" selected.validate().map_err(|_| ValidationError::new(\"capabilities\", \"no_overlap\"))?;",
|
" selected.validate().map_err(|_| ValidationError::new(\"capabilities\", \"no_overlap\"))?;",
|
||||||
@@ -512,7 +583,7 @@ def swift_validation(definition: dict[str, Any]) -> list[str]:
|
|||||||
allowed = ", ".join(f'\"{item}\"' for item in prop["enum"])
|
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\") }}")
|
lines.append(f" {prefix}if ![{allowed}].contains({value}) {{ throw ContractValidationError(field: \"{prop_name}\", code: \"invalid_value\") }}")
|
||||||
if prop.get("format") == "date-time":
|
if prop.get("format") == "date-time":
|
||||||
lines.append(f" {prefix}if ISO8601DateFormatter().date(from: {value}) == nil {{ throw ContractValidationError(field: \"{prop_name}\", code: \"invalid_time\") }}")
|
lines.append(f" {prefix}if !validRFC3339UTC({value}) {{ throw ContractValidationError(field: \"{prop_name}\", code: \"invalid_time\") }}")
|
||||||
if prop.get("format") == "base64url":
|
if prop.get("format") == "base64url":
|
||||||
lines.append(f" {prefix}if !validBase64URL({value}) {{ throw ContractValidationError(field: \"{prop_name}\", code: \"invalid_format\") }}")
|
lines.append(f" {prefix}if !validBase64URL({value}) {{ throw ContractValidationError(field: \"{prop_name}\", code: \"invalid_format\") }}")
|
||||||
if prop.get("type") == "integer":
|
if prop.get("type") == "integer":
|
||||||
@@ -526,6 +597,12 @@ def swift_validation(definition: dict[str, Any]) -> list[str]:
|
|||||||
if "maxItems" in prop:
|
if "maxItems" in prop:
|
||||||
lines.append(f" {prefix}if {value}.count > {prop['maxItems']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"max_items\") }}")
|
lines.append(f" {prefix}if {value}.count > {prop['maxItems']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"max_items\") }}")
|
||||||
items = prop.get("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:
|
if "enum" in items:
|
||||||
allowed = ", ".join(f'\"{item}\"' for item in items["enum"])
|
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\") }}")
|
lines.append(f" {prefix}for item in {value} where ![{allowed}].contains(item) {{ throw ContractValidationError(field: \"{prop_name}\", code: \"invalid_item\") }}")
|
||||||
@@ -569,6 +646,23 @@ def generate_swift(defs: dict[str, dict[str, Any]], schema_hash: str, compatibil
|
|||||||
" guard let decoded = Data(base64Encoded: standard) else { return false }",
|
" guard let decoded = Data(base64Encoded: standard) else { return false }",
|
||||||
" return decoded.base64EncodedString().replacingOccurrences(of: \"+\", with: \"-\").replacingOccurrences(of: \"/\", with: \"_\").replacingOccurrences(of: \"=\", with: \"\") == value",
|
" 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):
|
||||||
@@ -603,6 +697,8 @@ def generate_swift(defs: dict[str, dict[str, Any]], schema_hash: str, compatibil
|
|||||||
typ = swift_type(prop)
|
typ = swift_type(prop)
|
||||||
if prop_name in required:
|
if prop_name in required:
|
||||||
decoded.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:
|
||||||
decoded.append(f"{field}: try c.decodeIfPresent({typ}.self, forKey: .{field})")
|
decoded.append(f"{field}: try c.decodeIfPresent({typ}.self, forKey: .{field})")
|
||||||
out.append(f" try self.init({', '.join(decoded)})")
|
out.append(f" try self.init({', '.join(decoded)})")
|
||||||
@@ -610,6 +706,21 @@ def generate_swift(defs: dict[str, dict[str, Any]], schema_hash: str, compatibil
|
|||||||
out.extend(swift_validation(definition))
|
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 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([
|
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 {",
|
"public extension TunnelAdmissionRequest {",
|
||||||
" func deviceAdmissionTranscript() -> Data {",
|
" 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)]",
|
" var fields = [sessionId, gatewayId, audience, grant, String(reconnectSequence), clientNonce, capabilities.transport, capabilities.framing, capabilities.media, capabilities.audio, capabilities.sourceRateControl, String(capabilities.clientDecode.count)]",
|
||||||
|
|||||||
@@ -38,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]))
|
||||||
}
|
}
|
||||||
@@ -49,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)
|
||||||
@@ -58,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"
|
||||||
@@ -81,7 +95,7 @@ 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}},
|
||||||
@@ -99,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"])
|
||||||
@@ -125,10 +193,12 @@ 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"
|
||||||
@@ -226,6 +296,19 @@ func classifyGatewayInput(encoded string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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:
|
default:
|
||||||
return "invalid:kind"
|
return "invalid:kind"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,9 +96,9 @@ 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",
|
||||||
@@ -79,6 +129,20 @@ fn evaluate(kind: &str, input: &str) -> &'static str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
fn classify_gateway_input(encoded: &str) -> &'static str {
|
||||||
let raw = match decode_hex(encoded) {
|
let raw = match decode_hex(encoded) {
|
||||||
Some(raw) => raw,
|
Some(raw) => raw,
|
||||||
@@ -110,6 +174,16 @@ fn classify_gateway_input(encoded: &str) -> &'static str {
|
|||||||
5 if body[0] > 15 => "invalid:field",
|
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 if body[1] == 0 && body[2] == 0 && body[3..].iter().any(|value| *value != 0) => "invalid:field",
|
||||||
5 => "valid",
|
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",
|
_ => "invalid:kind",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -273,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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,8 +59,9 @@ 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_input": return classifyGatewayInput(values["hex"] ?? "")
|
||||||
case "gateway_feedback": return classifyGatewayFeedback(values["hex"] ?? "")
|
case "gateway_feedback": return classifyGatewayFeedback(values["hex"] ?? "")
|
||||||
@@ -48,6 +81,19 @@ func evaluate(_ kind: String, _ input: 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]? {
|
func decodeHex(_ encoded: String) -> [UInt8]? {
|
||||||
let characters = Array(encoded)
|
let characters = Array(encoded)
|
||||||
guard characters.count % 2 == 0 else { return nil }
|
guard characters.count % 2 == 0 else { return nil }
|
||||||
@@ -82,6 +128,14 @@ func classifyGatewayInput(_ encoded: String) -> String {
|
|||||||
guard body[0] <= 15 else { return "invalid:field" }
|
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" }
|
guard body[1] != 0 || body[2] != 0 || body.dropFirst(3).allSatisfy({ $0 == 0 }) else { return "invalid:field" }
|
||||||
return "valid"
|
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"
|
default: return "invalid:kind"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,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)")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import pathlib
|
import pathlib
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -24,6 +25,32 @@ def run_failure(command: list[str], directory: pathlib.Path, expected: str) -> N
|
|||||||
raise RuntimeError("expected failure: %s\n%s%s" % (" ".join(command), result.stdout, result.stderr))
|
raise RuntimeError("expected failure: %s\n%s%s" % (" ".join(command), result.stdout, result.stderr))
|
||||||
|
|
||||||
|
|
||||||
|
def protobuf_message_fields(name: str) -> list[tuple[str, int]]:
|
||||||
|
result = subprocess.run(
|
||||||
|
["protoc", "--decode=google.protobuf.FileDescriptorSet", "google/protobuf/descriptor.proto"],
|
||||||
|
input=(ROOT / "gen/protobuf/tunnel-v1.pb").read_bytes(),
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(result.stderr.decode())
|
||||||
|
lines = result.stdout.decode().splitlines()
|
||||||
|
marker = f' name: "{name}"'
|
||||||
|
try:
|
||||||
|
name_index = lines.index(marker)
|
||||||
|
start = max(index for index in range(name_index) if lines[index] == " message_type {")
|
||||||
|
except (ValueError, StopIteration) as exc:
|
||||||
|
raise RuntimeError(f"protobuf descriptor missing message {name}") from exc
|
||||||
|
depth = 0
|
||||||
|
block: list[str] = []
|
||||||
|
for line in lines[start:]:
|
||||||
|
depth += line.count("{") - line.count("}")
|
||||||
|
block.append(line)
|
||||||
|
if depth == 0:
|
||||||
|
break
|
||||||
|
return [(field, int(number)) for field, number in re.findall(r' field \{\n name: "([^"]+)"\n number: (\d+)', "\n".join(block))]
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
with tempfile.TemporaryDirectory(prefix="versevdi-generated-contracts-") as temporary:
|
with tempfile.TemporaryDirectory(prefix="versevdi-generated-contracts-") as temporary:
|
||||||
workspace = pathlib.Path(temporary)
|
workspace = pathlib.Path(temporary)
|
||||||
@@ -35,6 +62,9 @@ let capability = try CapabilityProfile(
|
|||||||
transport: "quic-tls13", framing: "datagram-v1", media: "encoded",
|
transport: "quic-tls13", framing: "datagram-v1", media: "encoded",
|
||||||
audio: "encoded", sourceRateControl: "server", clientDecode: ["h264-opus"]
|
audio: "encoded", sourceRateControl: "server", clientDecode: ["h264-opus"]
|
||||||
)
|
)
|
||||||
|
guard currentWireVersion == "2", nMinus1WireVersion == "1", nMinus2WireVersion == "0" else {
|
||||||
|
fatalError("unexpected control wire compatibility declaration")
|
||||||
|
}
|
||||||
_ = try CapabilityProfile(
|
_ = try CapabilityProfile(
|
||||||
transport: "quic-tls13", framing: "datagram-v2", media: "encoded",
|
transport: "quic-tls13", framing: "datagram-v2", media: "encoded",
|
||||||
audio: "encoded", sourceRateControl: "server", clientDecode: ["h264-opus"]
|
audio: "encoded", sourceRateControl: "server", clientDecode: ["h264-opus"]
|
||||||
@@ -56,6 +86,44 @@ let transcript = "versevdi/tunnel-admission/v17:session7:gateway8:audience43:" +
|
|||||||
guard String(data: request.deviceAdmissionTranscript(), encoding: .utf8) == transcript else {
|
guard String(data: request.deviceAdmissionTranscript(), encoding: .utf8) == transcript else {
|
||||||
fatalError("unexpected device admission transcript")
|
fatalError("unexpected device admission transcript")
|
||||||
}
|
}
|
||||||
|
let proofServerID = Data(repeating: 1, count: 16)
|
||||||
|
let proofPrincipalID = Data(repeating: 2, count: 16)
|
||||||
|
let proofDeviceID = Data(repeating: 3, count: 16)
|
||||||
|
let proofChallenge = Data(repeating: 4, count: 32)
|
||||||
|
let proofTranscript = try deviceRegistrationProofTranscript(
|
||||||
|
serverID: proofServerID, principalID: proofPrincipalID, deviceID: proofDeviceID,
|
||||||
|
challenge: proofChallenge, expiryUnixMilliseconds: 1
|
||||||
|
)
|
||||||
|
guard proofTranscript.count == 112,
|
||||||
|
String(data: proofTranscript.prefix(24), encoding: .utf8) == "versevdi-device-proof-v1",
|
||||||
|
Array(proofTranscript.suffix(8)) == [0, 0, 0, 0, 0, 0, 0, 1] else {
|
||||||
|
fatalError("unexpected device registration proof transcript")
|
||||||
|
}
|
||||||
|
let invalidProofInputs: [(String, String, Data, Data, Data, Data, Int64)] = [
|
||||||
|
("server-short", "server_id", Data(repeating: 0, count: 15), proofPrincipalID, proofDeviceID, proofChallenge, 0),
|
||||||
|
("server-long", "server_id", Data(repeating: 0, count: 17), proofPrincipalID, proofDeviceID, proofChallenge, 0),
|
||||||
|
("principal-short", "principal_id", proofServerID, Data(repeating: 0, count: 15), proofDeviceID, proofChallenge, 0),
|
||||||
|
("principal-long", "principal_id", proofServerID, Data(repeating: 0, count: 17), proofDeviceID, proofChallenge, 0),
|
||||||
|
("device-short", "device_id", proofServerID, proofPrincipalID, Data(repeating: 0, count: 15), proofChallenge, 0),
|
||||||
|
("device-long", "device_id", proofServerID, proofPrincipalID, Data(repeating: 0, count: 17), proofChallenge, 0),
|
||||||
|
("challenge-short", "challenge", proofServerID, proofPrincipalID, proofDeviceID, Data(repeating: 0, count: 31), 0),
|
||||||
|
("challenge-long", "challenge", proofServerID, proofPrincipalID, proofDeviceID, Data(repeating: 0, count: 33), 0),
|
||||||
|
("negative-expiry", "expiry_unix_milliseconds", proofServerID, proofPrincipalID, proofDeviceID, proofChallenge, -1),
|
||||||
|
]
|
||||||
|
for (name, field, serverID, principalID, deviceID, challenge, expiry) in invalidProofInputs {
|
||||||
|
do {
|
||||||
|
_ = try deviceRegistrationProofTranscript(
|
||||||
|
serverID: serverID, principalID: principalID, deviceID: deviceID,
|
||||||
|
challenge: challenge, expiryUnixMilliseconds: expiry
|
||||||
|
)
|
||||||
|
fatalError("\(name) was accepted")
|
||||||
|
} catch let error as ContractValidationError {
|
||||||
|
guard error.field == field,
|
||||||
|
error.code == (field == "expiry_unix_milliseconds" ? "minimum" : "invalid_length") else {
|
||||||
|
fatalError("\(name) returned the wrong validation error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let incompatible = try CapabilityProfile(
|
let incompatible = try CapabilityProfile(
|
||||||
transport: "quic-tls13", framing: "datagram-v1", media: "encoded",
|
transport: "quic-tls13", framing: "datagram-v1", media: "encoded",
|
||||||
audio: "encoded", sourceRateControl: "server", clientDecode: ["hevc-opus"]
|
audio: "encoded", sourceRateControl: "server", clientDecode: ["hevc-opus"]
|
||||||
@@ -95,6 +163,52 @@ for invalid in [
|
|||||||
fatalError("invalid tunnel admission request was accepted")
|
fatalError("invalid tunnel admission request was accepted")
|
||||||
} catch { }
|
} catch { }
|
||||||
}
|
}
|
||||||
|
let clientAuthority = try ClientSessionAuthority(
|
||||||
|
version: "1", sessionId: "session", gatewayId: "gateway", audience: "audience",
|
||||||
|
reconnectSequence: 2, expiresAt: "2099-01-01T00:00:00Z", capabilities: capability
|
||||||
|
)
|
||||||
|
let clientAuthorityJSON = try clientAuthority.encodeJSON()
|
||||||
|
let clientAuthorityObject = try JSONSerialization.jsonObject(with: clientAuthorityJSON) as! [String: Any]
|
||||||
|
guard Set(clientAuthorityObject.keys) == Set([
|
||||||
|
"version", "session_id", "gateway_id", "audience", "reconnect_sequence", "expires_at", "capabilities"
|
||||||
|
]), !String(data: clientAuthorityJSON, encoding: .utf8)!.contains("provider_") else {
|
||||||
|
fatalError("client authority was not exactly provider-free")
|
||||||
|
}
|
||||||
|
_ = try ClientSessionAuthority.decodeJSON(clientAuthorityJSON)
|
||||||
|
for field in ["version", "session_id", "gateway_id", "audience", "reconnect_sequence", "expires_at", "capabilities"] {
|
||||||
|
var missing = clientAuthorityObject
|
||||||
|
missing.removeValue(forKey: field)
|
||||||
|
do {
|
||||||
|
_ = try ClientSessionAuthority.decodeJSON(try JSONSerialization.data(withJSONObject: missing))
|
||||||
|
fatalError("client authority accepted missing \(field)")
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
for (field, value) in [
|
||||||
|
("provider_profile", "apollo"),
|
||||||
|
("provider_identity", "provider-1"),
|
||||||
|
("provider_url", "https://provider.invalid"),
|
||||||
|
("management_host", "provider.invalid"),
|
||||||
|
("unknown", "true"),
|
||||||
|
] {
|
||||||
|
var injected = clientAuthorityObject
|
||||||
|
injected[field] = value
|
||||||
|
do {
|
||||||
|
_ = try ClientSessionAuthority.decodeJSON(try JSONSerialization.data(withJSONObject: injected))
|
||||||
|
fatalError("client authority accepted injected \(field)")
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
for expiresAt in ["not-a-time", "2099-01-01T00:00:00+00:00", "2099-01-01T00:00:00.100Z"] {
|
||||||
|
var invalidExpiry = clientAuthorityObject
|
||||||
|
invalidExpiry["expires_at"] = expiresAt
|
||||||
|
do {
|
||||||
|
_ = try ClientSessionAuthority.decodeJSON(try JSONSerialization.data(withJSONObject: invalidExpiry))
|
||||||
|
fatalError("client authority accepted invalid expiry")
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
_ = try ClientSessionAuthority.decodeJSON(clientAuthorityJSON + Data(" {}".utf8))
|
||||||
|
fatalError("client authority accepted trailing JSON")
|
||||||
|
} catch { }
|
||||||
do {
|
do {
|
||||||
_ = try AllocationPolicy(
|
_ = try AllocationPolicy(
|
||||||
minimumKbps: 100, targetKbps: 50, maximumKbps: 25, tier: "standard",
|
minimumKbps: 100, targetKbps: 50, maximumKbps: 25, tier: "standard",
|
||||||
@@ -103,6 +217,83 @@ do {
|
|||||||
)
|
)
|
||||||
fatalError("invalid allocation bounds were accepted")
|
fatalError("invalid allocation bounds were accepted")
|
||||||
} catch { }
|
} catch { }
|
||||||
|
let displayMode = try DisplayMode(resolutionWidth: 2560, resolutionHeight: 1440, fps: 120)
|
||||||
|
for invalid in [
|
||||||
|
{ try DisplayMode(resolutionWidth: 319, resolutionHeight: 1440, fps: 120) },
|
||||||
|
{ try DisplayMode(resolutionWidth: 2560, resolutionHeight: 199, fps: 120) },
|
||||||
|
{ try DisplayMode(resolutionWidth: 2560, resolutionHeight: 1440, fps: 241) },
|
||||||
|
] {
|
||||||
|
do {
|
||||||
|
_ = try invalid()
|
||||||
|
fatalError("invalid display mode was accepted")
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
let policyFreeV2Request = try SessionRequest(
|
||||||
|
clientDeviceId: "device-1", deviceKeyId: "key-1", poolId: "pool-1",
|
||||||
|
idempotencyKey: "request-1", requestedDisplayMode: nil
|
||||||
|
).encodeJSON()
|
||||||
|
guard !String(data: policyFreeV2Request, encoding: .utf8)!.contains("requested_display_mode") else {
|
||||||
|
fatalError("wire-v2 request encoded an absent display mode")
|
||||||
|
}
|
||||||
|
let displayRequest = try SessionRequest(
|
||||||
|
clientDeviceId: "device-1", deviceKeyId: "key-1", poolId: "pool-1",
|
||||||
|
idempotencyKey: "request-1", requestedDisplayMode: displayMode
|
||||||
|
)
|
||||||
|
guard try SessionRequest.decodeJSON(displayRequest.encodeJSON()).requestedDisplayMode == displayMode else {
|
||||||
|
fatalError("display mode did not round-trip")
|
||||||
|
}
|
||||||
|
var nullDisplayRequest = try JSONSerialization.jsonObject(with: displayRequest.encodeJSON()) as! [String: Any]
|
||||||
|
nullDisplayRequest["requested_display_mode"] = NSNull()
|
||||||
|
do {
|
||||||
|
_ = try SessionRequest.decodeJSON(try JSONSerialization.data(withJSONObject: nullDisplayRequest))
|
||||||
|
fatalError("explicit null display mode was accepted")
|
||||||
|
} catch { }
|
||||||
|
let nativeIdentity = try NativeSessionIdentity(clientDeviceId: "device-1", deviceKeyId: "key-1")
|
||||||
|
let browserSession = try BrowserAuthenticatedSession(
|
||||||
|
username: "alice", provider: "local", roles: ["user"], role: "user"
|
||||||
|
)
|
||||||
|
guard !String(data: try browserSession.encodeJSON(), encoding: .utf8)!.contains("native_identity") else {
|
||||||
|
fatalError("browser session encoded native identity")
|
||||||
|
}
|
||||||
|
let nativeSession = try NativeAuthenticatedSession(
|
||||||
|
username: "alice", provider: "local", roles: ["user"], role: "user", nativeIdentity: nativeIdentity
|
||||||
|
)
|
||||||
|
guard try NativeAuthenticatedSession.decodeJSON(nativeSession.encodeJSON()).nativeIdentity == nativeIdentity else {
|
||||||
|
fatalError("native session identity did not round-trip")
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
_ = try BrowserAuthenticatedSession.decodeJSON(nativeSession.encodeJSON())
|
||||||
|
fatalError("browser session accepted native identity")
|
||||||
|
} catch { }
|
||||||
|
do {
|
||||||
|
_ = try NativeAuthenticatedSession.decodeJSON(browserSession.encodeJSON())
|
||||||
|
fatalError("native session accepted missing identity")
|
||||||
|
} catch { }
|
||||||
|
var partialNativeSession = try JSONSerialization.jsonObject(with: nativeSession.encodeJSON()) as! [String: Any]
|
||||||
|
partialNativeSession["native_identity"] = ["client_device_id": "device-1"]
|
||||||
|
do {
|
||||||
|
_ = try NativeAuthenticatedSession.decodeJSON(try JSONSerialization.data(withJSONObject: partialNativeSession))
|
||||||
|
fatalError("partial native identity was accepted")
|
||||||
|
} catch { }
|
||||||
|
for roles in [[""], [String(repeating: "r", count: 65)]] {
|
||||||
|
do {
|
||||||
|
_ = try BrowserAuthenticatedSession(username: "alice", provider: "local", roles: roles, role: "user")
|
||||||
|
fatalError("invalid role item length was accepted")
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
_ = try NativeTunnelCredential(
|
||||||
|
clientDeviceId: "device-1", deviceKeyId: "key-1", certificateChainPem: "certificate",
|
||||||
|
trustBundlePem: "trust", expiresAt: "2099-01-01T00:00:00Z"
|
||||||
|
)
|
||||||
|
for expiresAt in ["2099-01-01T00:00:00+00:00", "2099-01-01T00:00:00.100Z"] {
|
||||||
|
do {
|
||||||
|
_ = try NativeTunnelCredential(
|
||||||
|
clientDeviceId: "device-1", deviceKeyId: "key-1", certificateChainPem: "certificate",
|
||||||
|
trustBundlePem: "trust", expiresAt: expiresAt
|
||||||
|
)
|
||||||
|
fatalError("noncanonical RFC3339 UTC timestamp was accepted")
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
let streamPolicy = try ProviderStreamPolicy(
|
let streamPolicy = try ProviderStreamPolicy(
|
||||||
resolutionWidth: 2560, resolutionHeight: 1440, fps: 120,
|
resolutionWidth: 2560, resolutionHeight: 1440, fps: 120,
|
||||||
codec: "HEVC", bitrateKbps: 40000, audioEnabled: true
|
codec: "HEVC", bitrateKbps: 40000, audioEnabled: true
|
||||||
@@ -169,6 +360,9 @@ do {
|
|||||||
output.write(
|
output.write(
|
||||||
"""
|
"""
|
||||||
fn main() {
|
fn main() {
|
||||||
|
assert_eq!(CURRENT_WIRE_VERSION, "2");
|
||||||
|
assert_eq!(N_MINUS_1_WIRE_VERSION, "1");
|
||||||
|
assert_eq!(N_MINUS_2_WIRE_VERSION, "0");
|
||||||
let capabilities = CapabilityProfile::new(
|
let capabilities = CapabilityProfile::new(
|
||||||
"quic-tls13".into(), "datagram-v1".into(), "encoded".into(),
|
"quic-tls13".into(), "datagram-v1".into(), "encoded".into(),
|
||||||
"encoded".into(), "server".into(), vec!["h264-opus".into()],
|
"encoded".into(), "server".into(), vec!["h264-opus".into()],
|
||||||
@@ -189,6 +383,32 @@ fn main() {
|
|||||||
+ &"g".repeat(43) + "1:016:" + &"n".repeat(16)
|
+ &"g".repeat(43) + "1:016:" + &"n".repeat(16)
|
||||||
+ "10:quic-tls1311:datagram-v17:encoded7:encoded6:server1:19:h264-opus";
|
+ "10:quic-tls1311:datagram-v17:encoded7:encoded6:server1:19:h264-opus";
|
||||||
assert_eq!(request.device_admission_transcript(), transcript.into_bytes());
|
assert_eq!(request.device_admission_transcript(), transcript.into_bytes());
|
||||||
|
let proof_server_id = vec![1u8; 16];
|
||||||
|
let proof_principal_id = vec![2u8; 16];
|
||||||
|
let proof_device_id = vec![3u8; 16];
|
||||||
|
let proof_challenge = vec![4u8; 32];
|
||||||
|
let proof = device_registration_proof_transcript(
|
||||||
|
&proof_server_id, &proof_principal_id, &proof_device_id, &proof_challenge, 1,
|
||||||
|
).unwrap();
|
||||||
|
assert_eq!(proof.len(), 112);
|
||||||
|
assert_eq!(&proof[..24], b"versevdi-device-proof-v1");
|
||||||
|
assert_eq!(&proof[104..], &[0, 0, 0, 0, 0, 0, 0, 1]);
|
||||||
|
for (server_id, principal_id, device_id, challenge, expiry, field, code) in [
|
||||||
|
(vec![0; 15], proof_principal_id.clone(), proof_device_id.clone(), proof_challenge.clone(), 0, "server_id", "invalid_length"),
|
||||||
|
(vec![0; 17], proof_principal_id.clone(), proof_device_id.clone(), proof_challenge.clone(), 0, "server_id", "invalid_length"),
|
||||||
|
(proof_server_id.clone(), vec![0; 15], proof_device_id.clone(), proof_challenge.clone(), 0, "principal_id", "invalid_length"),
|
||||||
|
(proof_server_id.clone(), vec![0; 17], proof_device_id.clone(), proof_challenge.clone(), 0, "principal_id", "invalid_length"),
|
||||||
|
(proof_server_id.clone(), proof_principal_id.clone(), vec![0; 15], proof_challenge.clone(), 0, "device_id", "invalid_length"),
|
||||||
|
(proof_server_id.clone(), proof_principal_id.clone(), vec![0; 17], proof_challenge.clone(), 0, "device_id", "invalid_length"),
|
||||||
|
(proof_server_id.clone(), proof_principal_id.clone(), proof_device_id.clone(), vec![0; 31], 0, "challenge", "invalid_length"),
|
||||||
|
(proof_server_id.clone(), proof_principal_id.clone(), proof_device_id.clone(), vec![0; 33], 0, "challenge", "invalid_length"),
|
||||||
|
(proof_server_id.clone(), proof_principal_id.clone(), proof_device_id.clone(), proof_challenge.clone(), -1, "expiry_unix_milliseconds", "minimum"),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
device_registration_proof_transcript(&server_id, &principal_id, &device_id, &challenge, expiry),
|
||||||
|
Err(ValidationError::new(field, code)),
|
||||||
|
);
|
||||||
|
}
|
||||||
assert!(TunnelAdmissionRequest::new(
|
assert!(TunnelAdmissionRequest::new(
|
||||||
"2".into(), "session".into(), "gateway".into(), "audience".into(),
|
"2".into(), "session".into(), "gateway".into(), "audience".into(),
|
||||||
"g".repeat(43), 0, "n".repeat(16), "s".repeat(86), capabilities.clone(),
|
"g".repeat(43), 0, "n".repeat(16), "s".repeat(86), capabilities.clone(),
|
||||||
@@ -201,6 +421,16 @@ fn main() {
|
|||||||
"1".into(), "session".into(), "gateway".into(), "audience".into(),
|
"1".into(), "session".into(), "gateway".into(), "audience".into(),
|
||||||
"g".repeat(43), 0, "short".into(), "s".repeat(86), capabilities.clone(),
|
"g".repeat(43), 0, "short".into(), "s".repeat(86), capabilities.clone(),
|
||||||
).is_err());
|
).is_err());
|
||||||
|
let client_authority = ClientSessionAuthority::new(
|
||||||
|
"1".into(), "session".into(), "gateway".into(), "audience".into(), 2,
|
||||||
|
"2099-01-01T00:00:00Z".into(), capabilities.clone(),
|
||||||
|
).unwrap();
|
||||||
|
assert_eq!(client_authority.sessionId(), "session");
|
||||||
|
assert_eq!(client_authority.capabilities(), &capabilities);
|
||||||
|
assert!(ClientSessionAuthority::new(
|
||||||
|
"1".into(), "session".into(), "gateway".into(), "audience".into(), 2,
|
||||||
|
"not-a-time".into(), capabilities.clone(),
|
||||||
|
).is_err());
|
||||||
assert!(intersect_capability_profiles(&[capabilities.clone(), capabilities.clone()]).is_ok());
|
assert!(intersect_capability_profiles(&[capabilities.clone(), capabilities.clone()]).is_ok());
|
||||||
let incompatible = CapabilityProfile::new(
|
let incompatible = CapabilityProfile::new(
|
||||||
"quic-tls13".into(), "datagram-v1".into(), "encoded".into(),
|
"quic-tls13".into(), "datagram-v1".into(), "encoded".into(),
|
||||||
@@ -218,6 +448,43 @@ fn main() {
|
|||||||
assert!(AllocationPolicy::new(
|
assert!(AllocationPolicy::new(
|
||||||
100, 50, 25, "standard".into(), "audience".into(), "verse".into(), 1, 60, 300,
|
100, 50, 25, "standard".into(), "audience".into(), "verse".into(), 1, 60, 300,
|
||||||
).is_err());
|
).is_err());
|
||||||
|
let display_mode = DisplayMode::new(2560, 1440, 120).unwrap();
|
||||||
|
assert!(DisplayMode::new(319, 1440, 120).is_err());
|
||||||
|
assert!(DisplayMode::new(2560, 199, 120).is_err());
|
||||||
|
assert!(DisplayMode::new(2560, 1440, 241).is_err());
|
||||||
|
let policy_free_v2_request = SessionRequest::new(
|
||||||
|
"device-1".into(), "key-1".into(), "pool-1".into(), "request-1".into(),
|
||||||
|
None,
|
||||||
|
).unwrap();
|
||||||
|
assert!(policy_free_v2_request.requestedDisplayMode().is_none());
|
||||||
|
let display_request = SessionRequest::new(
|
||||||
|
"device-1".into(), "key-1".into(), "pool-1".into(), "request-1".into(),
|
||||||
|
Some(display_mode.clone()),
|
||||||
|
).unwrap();
|
||||||
|
assert_eq!(display_request.requestedDisplayMode(), &Some(display_mode));
|
||||||
|
let native_identity = NativeSessionIdentity::new("device-1".into(), "key-1".into()).unwrap();
|
||||||
|
assert!(BrowserAuthenticatedSession::new(
|
||||||
|
"alice".into(), "local".into(), vec!["user".into()], "user".into(),
|
||||||
|
).is_ok());
|
||||||
|
assert!(NativeAuthenticatedSession::new(
|
||||||
|
"alice".into(), "local".into(), vec!["user".into()], "user".into(), native_identity,
|
||||||
|
).is_ok());
|
||||||
|
assert!(BrowserAuthenticatedSession::new(
|
||||||
|
"alice".into(), "local".into(), vec![String::new()], "user".into(),
|
||||||
|
).is_err());
|
||||||
|
assert!(BrowserAuthenticatedSession::new(
|
||||||
|
"alice".into(), "local".into(), vec!["r".repeat(65)], "user".into(),
|
||||||
|
).is_err());
|
||||||
|
assert!(NativeTunnelCredential::new(
|
||||||
|
"device-1".into(), "key-1".into(), "certificate".into(), "trust".into(),
|
||||||
|
"2099-01-01T00:00:00Z".into(),
|
||||||
|
).is_ok());
|
||||||
|
for expires_at in ["2099-01-01T00:00:00+00:00", "2099-01-01T00:00:00.100Z"] {
|
||||||
|
assert!(NativeTunnelCredential::new(
|
||||||
|
"device-1".into(), "key-1".into(), "certificate".into(), "trust".into(),
|
||||||
|
expires_at.into(),
|
||||||
|
).is_err());
|
||||||
|
}
|
||||||
assert!(ProviderStreamPolicy::new(
|
assert!(ProviderStreamPolicy::new(
|
||||||
2560, 1440, 120, "HEVC".into(), 40000, true,
|
2560, 1440, 120, "HEVC".into(), 40000, true,
|
||||||
).is_ok());
|
).is_ok());
|
||||||
@@ -257,6 +524,21 @@ fn main() {
|
|||||||
)
|
)
|
||||||
run(["rustc", str(rust), "-o", str(workspace / "rust-contracts")], ROOT)
|
run(["rustc", str(rust), "-o", str(workspace / "rust-contracts")], ROOT)
|
||||||
run([str(workspace / "rust-contracts")], ROOT)
|
run([str(workspace / "rust-contracts")], ROOT)
|
||||||
|
expected_protobuf_fields = [
|
||||||
|
("version", 1),
|
||||||
|
("session_id", 2),
|
||||||
|
("gateway_id", 3),
|
||||||
|
("audience", 4),
|
||||||
|
("reconnect_sequence", 5),
|
||||||
|
("expires_at", 6),
|
||||||
|
("capabilities", 7),
|
||||||
|
]
|
||||||
|
actual_protobuf_fields = protobuf_message_fields("ClientSessionAuthority")
|
||||||
|
if actual_protobuf_fields != expected_protobuf_fields:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"ClientSessionAuthority protobuf fields = {actual_protobuf_fields}; "
|
||||||
|
f"want {expected_protobuf_fields}"
|
||||||
|
)
|
||||||
rust_unknown = workspace / "unknown.rs"
|
rust_unknown = workspace / "unknown.rs"
|
||||||
shutil.copyfile(ROOT / "gen/rust/protocol.rs", rust_unknown)
|
shutil.copyfile(ROOT / "gen/rust/protocol.rs", rust_unknown)
|
||||||
with rust_unknown.open("a", encoding="utf-8") as output:
|
with rust_unknown.open("a", encoding="utf-8") as output:
|
||||||
|
|||||||
+123
-2
@@ -24,7 +24,7 @@ def main() -> int:
|
|||||||
assert set(definition["required"]).issubset(definition["properties"]), name
|
assert set(definition["required"]).issubset(definition["properties"]), name
|
||||||
|
|
||||||
compatibility = json.loads((ROOT / "compatibility.json").read_text(encoding="utf-8"))
|
compatibility = json.loads((ROOT / "compatibility.json").read_text(encoding="utf-8"))
|
||||||
assert set([compatibility["current"], compatibility["n_minus_1"], compatibility["n_minus_2"]]) == {"1", "0", "-1"}
|
assert [compatibility["current"], compatibility["n_minus_1"], compatibility["n_minus_2"]] == ["2", "1", "0"]
|
||||||
assert len(set(compatibility["unsupported"])) == len(compatibility["unsupported"])
|
assert len(set(compatibility["unsupported"])) == len(compatibility["unsupported"])
|
||||||
|
|
||||||
for registry in ("registries/features.json", "registries/datagrams.json"):
|
for registry in ("registries/features.json", "registries/datagrams.json"):
|
||||||
@@ -36,10 +36,83 @@ def main() -> int:
|
|||||||
maximum = entry.get("max_frame_bytes", entry.get("max_payload_bytes"))
|
maximum = entry.get("max_frame_bytes", entry.get("max_payload_bytes"))
|
||||||
assert isinstance(maximum, int) and 1 <= maximum <= maximum_bound
|
assert isinstance(maximum, int) and 1 <= maximum <= maximum_bound
|
||||||
|
|
||||||
|
feature_registry = json.loads((ROOT / "registries/features.json").read_text(encoding="utf-8"))
|
||||||
|
registered_features = {entry["id"] for entry in feature_registry["features"]}
|
||||||
|
assert {"control.v1", "control.v2", "display.request.v1", "input.absolute.v1", "input.scroll.v1"}.issubset(registered_features)
|
||||||
|
|
||||||
|
display_mode = defs["DisplayMode"]
|
||||||
|
assert display_mode["required"] == ["resolution_width", "resolution_height", "fps"]
|
||||||
|
assert display_mode["properties"]["resolution_width"] == {"type": "integer", "minimum": 320, "maximum": 16384}
|
||||||
|
assert display_mode["properties"]["resolution_height"] == {"type": "integer", "minimum": 200, "maximum": 8640}
|
||||||
|
assert display_mode["properties"]["fps"] == {"type": "integer", "minimum": 1, "maximum": 240}
|
||||||
|
for owner, field in (
|
||||||
|
("SessionRequest", "requested_display_mode"),
|
||||||
|
("BrokerSession", "requested_display_mode"),
|
||||||
|
("BrokerSession", "effective_display_mode"),
|
||||||
|
("ManifestProfile", "display_mode"),
|
||||||
|
):
|
||||||
|
assert field not in defs[owner]["required"]
|
||||||
|
assert defs[owner]["properties"][field] == {"$ref": "#/$defs/DisplayMode"}
|
||||||
|
|
||||||
|
session_request = defs["SessionRequest"]
|
||||||
|
assert "policy_snapshot" not in session_request["required"]
|
||||||
|
assert "policy_snapshot" not in session_request["properties"]
|
||||||
|
assert "policy_snapshot" in defs["BrokerSession"]["required"]
|
||||||
|
assert defs["BrokerSession"]["properties"]["policy_snapshot"] == {"$ref": "#/$defs/AllocationPolicy"}
|
||||||
|
|
||||||
|
native_identity = defs["NativeSessionIdentity"]
|
||||||
|
assert native_identity["required"] == ["client_device_id", "device_key_id"]
|
||||||
|
browser_session = defs["BrowserAuthenticatedSession"]
|
||||||
|
assert browser_session["required"] == ["username", "provider", "roles", "role"]
|
||||||
|
assert "native_identity" not in browser_session["properties"]
|
||||||
|
native_session = defs["NativeAuthenticatedSession"]
|
||||||
|
assert native_session["required"] == ["username", "provider", "roles", "role", "native_identity"]
|
||||||
|
assert native_session["properties"]["native_identity"] == {"$ref": "#/$defs/NativeSessionIdentity"}
|
||||||
|
for session_definition in (browser_session, native_session):
|
||||||
|
assert session_definition["properties"]["roles"]["items"] == {
|
||||||
|
"type": "string", "minLength": 1, "maxLength": 64, "x-max-bytes": 64
|
||||||
|
}
|
||||||
|
tunnel_credential = defs["NativeTunnelCredential"]
|
||||||
|
assert tunnel_credential["required"] == [
|
||||||
|
"client_device_id", "device_key_id", "certificate_chain_pem", "trust_bundle_pem", "expires_at"
|
||||||
|
]
|
||||||
|
client_authority_expiry = defs["ClientSessionAuthority"]["properties"]["expires_at"]
|
||||||
|
assert client_authority_expiry["format"] == "date-time"
|
||||||
|
client_authority_expiry_pattern = re.compile(client_authority_expiry.get("pattern", r"(?!)"))
|
||||||
|
assert client_authority_expiry_pattern.fullmatch("2099-01-01T00:00:00Z"), "client authority expiry must accept canonical UTC"
|
||||||
|
for noncanonical_expiry in ("2099-01-01T00:00:00+00:00", "2099-01-01T00:00:00.100Z"):
|
||||||
|
assert not client_authority_expiry_pattern.fullmatch(noncanonical_expiry), (
|
||||||
|
f"client authority expiry accepted noncanonical UTC {noncanonical_expiry}"
|
||||||
|
)
|
||||||
|
|
||||||
manifest = json.loads((ROOT / "fixtures/valid/manifest.json").read_text(encoding="utf-8"))
|
manifest = json.loads((ROOT / "fixtures/valid/manifest.json").read_text(encoding="utf-8"))
|
||||||
assert set(manifest).issubset(set(defs["ConnectionManifest"]["properties"]))
|
assert set(manifest).issubset(set(defs["ConnectionManifest"]["properties"]))
|
||||||
|
public_identity = manifest["gateway"]["public_identity"]
|
||||||
|
assert public_identity == "gateway.control.test"
|
||||||
|
assert public_identity not in {
|
||||||
|
manifest["gateway"]["id"],
|
||||||
|
*manifest["gateway"]["addresses"],
|
||||||
|
"sha256:" + "00" * 32,
|
||||||
|
"apollo-provider-1",
|
||||||
|
}
|
||||||
forbidden = json.loads((ROOT / "fixtures/invalid/manifest-provider-field.json").read_text(encoding="utf-8"))
|
forbidden = json.loads((ROOT / "fixtures/invalid/manifest-provider-field.json").read_text(encoding="utf-8"))
|
||||||
assert "provider_url" not in defs["ConnectionManifest"]["properties"] and "provider_url" in forbidden
|
assert "provider_url" not in defs["ConnectionManifest"]["properties"] and "provider_url" in forbidden
|
||||||
|
session_request_fixture = json.loads((ROOT / "fixtures/valid/session-request.json").read_text(encoding="utf-8"))
|
||||||
|
assert "policy_snapshot" not in session_request_fixture
|
||||||
|
rejected_policy_fixture = json.loads((ROOT / "fixtures/invalid/session-request-policy-snapshot.json").read_text(encoding="utf-8"))
|
||||||
|
assert "policy_snapshot" in rejected_policy_fixture
|
||||||
|
browser_session_fixture = json.loads((ROOT / "fixtures/valid/authenticated-browser-session.json").read_text(encoding="utf-8"))
|
||||||
|
assert "native_identity" not in browser_session_fixture
|
||||||
|
native_session_fixture = json.loads((ROOT / "fixtures/valid/authenticated-native-session.json").read_text(encoding="utf-8"))
|
||||||
|
assert set(native_session_fixture["native_identity"]) == {"client_device_id", "device_key_id"}
|
||||||
|
partial_identity_fixture = json.loads((ROOT / "fixtures/invalid/authenticated-session-partial-native-identity.json").read_text(encoding="utf-8"))
|
||||||
|
assert set(partial_identity_fixture["native_identity"]) != {"client_device_id", "device_key_id"}
|
||||||
|
browser_native_fixture = json.loads((ROOT / "fixtures/invalid/browser-session-native-identity.json").read_text(encoding="utf-8"))
|
||||||
|
assert "native_identity" in browser_native_fixture
|
||||||
|
native_missing_fixture = json.loads((ROOT / "fixtures/invalid/native-session-missing-identity.json").read_text(encoding="utf-8"))
|
||||||
|
assert "native_identity" not in native_missing_fixture
|
||||||
|
tunnel_credential_fixture = json.loads((ROOT / "fixtures/valid/native-tunnel-credential.json").read_text(encoding="utf-8"))
|
||||||
|
assert set(tunnel_credential_fixture) == set(tunnel_credential["required"])
|
||||||
|
|
||||||
expected_header = "id\tversion\tkind\tinput\texpected"
|
expected_header = "id\tversion\tkind\tinput\texpected"
|
||||||
ids = set()
|
ids = set()
|
||||||
@@ -51,7 +124,9 @@ def main() -> int:
|
|||||||
assert len(fields) == 5, line
|
assert len(fields) == 5, line
|
||||||
assert fields[0] not in ids, fields[0]
|
assert fields[0] not in ids, fields[0]
|
||||||
ids.add(fields[0])
|
ids.add(fields[0])
|
||||||
assert fields[4] == "valid" or fields[4].startswith("invalid:"), line
|
assert fields[4] == "valid" or fields[4].startswith("invalid:") or (
|
||||||
|
fields[2] == "device_proof_transcript" and re.fullmatch(r"[0-9a-f]{224}", fields[4])
|
||||||
|
), line
|
||||||
|
|
||||||
fixture_manifest = json.loads((ROOT / "fixtures/manifest.json").read_text(encoding="utf-8"))
|
fixture_manifest = json.loads((ROOT / "fixtures/manifest.json").read_text(encoding="utf-8"))
|
||||||
assert fixture_manifest["files"] == sorted(
|
assert fixture_manifest["files"] == sorted(
|
||||||
@@ -69,6 +144,52 @@ def main() -> int:
|
|||||||
assert "openapi: 3.1.0" in openapi
|
assert "openapi: 3.1.0" in openapi
|
||||||
assert "/api/v1/auth/refresh:" in openapi and "/api/v1/resources:" in openapi and "/api/v1/events:" in openapi
|
assert "/api/v1/auth/refresh:" in openapi and "/api/v1/resources:" in openapi and "/api/v1/events:" in openapi
|
||||||
assert "provider_url" not in openapi and "vm_address" not in openapi
|
assert "provider_url" not in openapi and "vm_address" not in openapi
|
||||||
|
session_endpoint = openapi.split(" /api/v1/auth/session:", 1)[1].split("\n /api/", 1)[0]
|
||||||
|
assert "$defs/BrowserAuthenticatedSession" in session_endpoint
|
||||||
|
assert "$defs/NativeAuthenticatedSession" in session_endpoint
|
||||||
|
login_endpoint = openapi.split(" /api/v1/auth/login:", 1)[1].split("\n /api/", 1)[0]
|
||||||
|
assert "$defs/BrowserAuthenticatedSession" in login_endpoint
|
||||||
|
assert "$defs/NativeAuthenticatedSession" not in login_endpoint
|
||||||
|
tunnel_endpoint = openapi.split(" /api/v1/auth/tunnel-credentials:", 1)[1].split("\n /api/", 1)[0]
|
||||||
|
assert "- nativeBearer: []" in tunnel_endpoint
|
||||||
|
assert "browserSession" not in tunnel_endpoint and "requestBody:" not in tunnel_endpoint
|
||||||
|
assert "$defs/NativeTunnelCredential" in tunnel_endpoint
|
||||||
|
assert "Cache-Control:" in tunnel_endpoint and "const: no-store" in tunnel_endpoint
|
||||||
|
csrf_schemes = """ 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.
|
||||||
|
"""
|
||||||
|
assert csrf_schemes in openapi, "missing exact browser CSRF security schemes"
|
||||||
|
browser_requirement = """ security:
|
||||||
|
- browserSession: []
|
||||||
|
browserCsrfCookie: []
|
||||||
|
browserCsrfHeader: []
|
||||||
|
"""
|
||||||
|
for operation_id in (
|
||||||
|
"issueReauthenticationGrant", "logoutSession", "registerDevice", "proveDevice", "revokeDevice",
|
||||||
|
"requestBrokerSession", "allocateBrokerSession", "reconnectBrokerSession", "cancelBrokerSession",
|
||||||
|
):
|
||||||
|
operation = openapi.split(f" operationId: {operation_id}\n", 1)[1].split(" responses:\n", 1)[0]
|
||||||
|
assert browser_requirement.removeprefix(" ") in operation, f"{operation_id}: missing browser CSRF AND requirement"
|
||||||
|
for operation_id in ("logoutSession", "requestBrokerSession", "allocateBrokerSession", "reconnectBrokerSession", "cancelBrokerSession"):
|
||||||
|
operation = openapi.split(f" operationId: {operation_id}\n", 1)[1].split(" responses:\n", 1)[0]
|
||||||
|
assert " browserCsrfHeader: []\n - nativeBearer: []\n" in operation, f"{operation_id}: native bearer must remain a separate OR requirement"
|
||||||
|
for operation_id in ("loginBrowserSession", "rotateNativeCredential", "issueNativeTunnelCredential"):
|
||||||
|
operation = openapi.split(f" operationId: {operation_id}\n", 1)[1].split(" responses:\n", 1)[0]
|
||||||
|
assert "browserCsrf" not in operation, f"{operation_id}: excluded operation gained browser CSRF"
|
||||||
|
for operation_id in ("getAuthenticatedSession", "listResources", "getBrokerSession", "resumeUserEvents"):
|
||||||
|
operation = openapi.split(f" operationId: {operation_id}\n", 1)[1].split(" responses:\n", 1)[0]
|
||||||
|
assert "browserCsrf" not in operation, f"{operation_id}: safe GET gained browser CSRF"
|
||||||
|
assert defs["ManifestGateway"]["properties"]["public_identity"]["description"] == (
|
||||||
|
"Exact TLS server name; distinct from dial addresses, gateway UUIDs, certificate fingerprints, and provider identities."
|
||||||
|
)
|
||||||
print("Protocol source validation passed")
|
print("Protocol source validation passed")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ def classify_input(raw: bytes) -> str:
|
|||||||
return "invalid:field"
|
return "invalid:field"
|
||||||
active_mask = int.from_bytes(body[1:3], "big")
|
active_mask = int.from_bytes(body[1:3], "big")
|
||||||
return "valid" if active_mask or not any(body[3:]) else "invalid:field"
|
return "valid" if active_mask or not any(body[3:]) else "invalid:field"
|
||||||
|
if kind == 6:
|
||||||
|
if len(body) != 8:
|
||||||
|
return "invalid:length"
|
||||||
|
x, y, width, height = (int.from_bytes(body[index:index + 2], "big") for index in range(0, 8, 2))
|
||||||
|
return "valid" if width and height and x < width and y < height else "invalid:field"
|
||||||
|
if kind == 7:
|
||||||
|
return "valid" if len(body) == 4 else "invalid:length"
|
||||||
return "invalid:kind"
|
return "invalid:kind"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user