Compare commits

..
Author SHA1 Message Date
sechmachine afbcea62f9 Protocol: split client session authority
Verify Protocol / verify (push) Successful in 1m2s
Verify Protocol / module (push) Successful in 1m45s
2026-08-12 11:50:41 +07:00
sechmachine b6a4f773e4 Protocol: freeze device proof and browser CSRF contracts
Verify Protocol / module (push) Successful in 1m12s
Verify Protocol / verify (push) Successful in 22s
2026-08-11 21:21:28 +07:00
sechmachine afcd5d99db feat(protocol): define native session credentials
Verify Protocol / module (push) Successful in 1m13s
Verify Protocol / verify (push) Successful in 35s
2026-08-11 18:38:19 +07:00
sechmachine 79d9e49497 docs(openspec): archive phase 3d display input 2026-08-10 23:09:58 +07:00
48 changed files with 2476 additions and 160 deletions
+1 -1
View File
@@ -21,10 +21,10 @@
# Go workspace file
go.work
go.work.sum
/.build
# env file
.env
# IDE files
/.idea
+1
View File
@@ -32,6 +32,7 @@ go-test:
binding-compile:
rustc --crate-type lib gen/rust/protocol.rs -o /tmp/versevdi-protocol-generated.rlib
swiftc -typecheck gen/swift/Protocol.swift
swift build
strict-contracts:
$(PYTHON) -B tools/test_generated_contracts.py
+13
View File
@@ -0,0 +1,13 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "VerseVDIProtocol",
platforms: [.macOS(.v14)],
products: [
.library(name: "VerseVDIProtocol", targets: ["VerseVDIProtocol"]),
],
targets: [
.target(name: "VerseVDIProtocol", path: "gen/swift", sources: ["Protocol.swift"]),
]
)
+4 -4
View File
@@ -1,10 +1,10 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"protocol": "versevdi-control",
"current": "1",
"n_minus_1": "0",
"n_minus_2": "-1",
"unsupported": ["-2", "2"],
"current": "2",
"n_minus_1": "1",
"n_minus_2": "0",
"unsupported": ["-1", "3"],
"feature_registry": "registries/features.json",
"datagram_registry": "registries/datagrams.json"
}
+26 -6
View File
@@ -1,11 +1,31 @@
id version kind input expected
version-current 1 version 1 valid
version-n-minus-1 0 version 0 valid
version-n-minus-2 -1 version -1 valid
version-unsupported 2 version 2 invalid:unsupported_version
version-current 2 version 2 valid
version-n-minus-1 1 version 1 valid
version-n-minus-2 0 version 0 valid
version-unsupported 3 version 3 invalid:unsupported_version
page-valid 1 page limit=20;cursor=opaque valid
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-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-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-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-file 1 clipboard encoding=octet-stream;file=/tmp/a invalid:unsupported_clipboard
session-request-policy-free 2 session_request client_device_id=device-1;device_key_id=key-1;pool_id=pool-1;idempotency_key=request-1 valid
session-request-wire-v1-shape 1 session_request client_device_id=device-1;device_key_id=key-1;pool_id=pool-1;idempotency_key=request-1;policy_snapshot=client-owned invalid:unsupported_version
session-request-client-policy 2 session_request client_device_id=device-1;device_key_id=key-1;pool_id=pool-1;idempotency_key=request-1;policy_snapshot=client-owned invalid:forbidden_field
authenticated-browser-session 2 browser_authenticated_session username=alice;provider=local;roles=user;role=user valid
authenticated-browser-native-identity 2 browser_authenticated_session username=alice;provider=local;roles=user;role=user;client_device_id=device-1;device_key_id=key-1 invalid:forbidden_field
authenticated-browser-empty-role 2 browser_authenticated_session username=alice;provider=local;roles=;role=user invalid:invalid_session
authenticated-browser-long-role 2 browser_authenticated_session username=alice;provider=local;roles=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;role=user invalid:invalid_session
authenticated-browser-role-64-utf8-bytes 2 browser_authenticated_session username=alice;provider=local;roles=éééééééééééééééééééééééééééééééé;role=user valid
authenticated-browser-role-66-utf8-bytes 2 browser_authenticated_session username=alice;provider=local;roles=ééééééééééééééééééééééééééééééééé;role=user invalid:invalid_session
authenticated-native-session 2 native_authenticated_session username=alice;provider=local;roles=user;role=user;client_device_id=device-1;device_key_id=key-1 valid
authenticated-native-missing-identity 2 native_authenticated_session username=alice;provider=local;roles=user;role=user invalid:required
authenticated-partial-native-session 2 native_authenticated_session username=alice;provider=local;roles=user;role=user;client_device_id=device-1 invalid:required
authenticated-native-empty-role 2 native_authenticated_session username=alice;provider=local;roles=;role=user;client_device_id=device-1;device_key_id=key-1 invalid:invalid_session
authenticated-native-long-role 2 native_authenticated_session username=alice;provider=local;roles=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;role=user;client_device_id=device-1;device_key_id=key-1 invalid:invalid_session
authenticated-native-role-64-utf8-bytes 2 native_authenticated_session username=alice;provider=local;roles=éééééééééééééééééééééééééééééééé;role=user;client_device_id=device-1;device_key_id=key-1 valid
authenticated-native-role-66-utf8-bytes 2 native_authenticated_session username=alice;provider=local;roles=ééééééééééééééééééééééééééééééééé;role=user;client_device_id=device-1;device_key_id=key-1 invalid:invalid_session
native-tunnel-credential 2 native_tunnel_credential client_device_id=device-1;device_key_id=key-1;certificate_chain_pem=certificate;trust_bundle_pem=trust;expires_at=2099-01-01T00:00:00Z valid
native-tunnel-credential-offset-time 2 native_tunnel_credential client_device_id=device-1;device_key_id=key-1;certificate_chain_pem=certificate;trust_bundle_pem=trust;expires_at=2099-01-01T00:00:00+00:00 invalid:invalid_credential
native-tunnel-credential-noncanonical-fraction 2 native_tunnel_credential client_device_id=device-1;device_key_id=key-1;certificate_chain_pem=certificate;trust_bundle_pem=trust;expires_at=2099-01-01T00:00:00.100Z invalid:invalid_credential
1 id version kind input expected
2 version-current 1 2 version 1 2 valid
3 version-n-minus-1 0 1 version 0 1 valid
4 version-n-minus-2 -1 0 version -1 0 valid
5 version-unsupported 2 3 version 2 3 invalid:unsupported_version
6 page-valid 1 page limit=20;cursor=opaque valid
7 page-limit-high 1 page limit=101 invalid:invalid_limit
8 manifest-valid 1 manifest version=1;gateway_id=g-1;grant=opaque-one-time-grant-value-with-at-least-43-bytes;audience=versevdi-gateway;purpose=launch;protocol=verse-gateway-v1;expires_at=2099-01-01T00:00:00Z version=1;gateway_id=g-1;public_identity=gateway.control.test;grant=opaque-one-time-grant-value-with-at-least-43-bytes;audience=versevdi-gateway;purpose=launch;protocol=verse-gateway-v1;expires_at=2099-01-01T00:00:00Z valid
9 manifest-provider-field manifest-missing-public-identity 1 manifest gateway_id=g-1;grant=g-1;audience=versevdi-gateway;purpose=launch;provider_url=https://provider.invalid version=1;gateway_id=g-1;grant=opaque-one-time-grant-value-with-at-least-43-bytes;audience=versevdi-gateway;purpose=launch;protocol=verse-gateway-v1;expires_at=2099-01-01T00:00:00Z invalid:forbidden_field invalid:invalid_manifest
10 manifest-provider-field 1 manifest gateway_id=g-1;public_identity=gateway.control.test;grant=g-1;audience=versevdi-gateway;purpose=launch;provider_url=https://provider.invalid invalid:forbidden_field
11 clipboard-text-valid 1 clipboard encoding=utf-8;text=hello%20world valid
12 clipboard-file 1 clipboard encoding=octet-stream;file=/tmp/a invalid:unsupported_clipboard
13 session-request-policy-free 2 session_request client_device_id=device-1;device_key_id=key-1;pool_id=pool-1;idempotency_key=request-1 valid
14 session-request-wire-v1-shape 1 session_request client_device_id=device-1;device_key_id=key-1;pool_id=pool-1;idempotency_key=request-1;policy_snapshot=client-owned invalid:unsupported_version
15 session-request-client-policy 2 session_request client_device_id=device-1;device_key_id=key-1;pool_id=pool-1;idempotency_key=request-1;policy_snapshot=client-owned invalid:forbidden_field
16 authenticated-browser-session 2 browser_authenticated_session username=alice;provider=local;roles=user;role=user valid
17 authenticated-browser-native-identity 2 browser_authenticated_session username=alice;provider=local;roles=user;role=user;client_device_id=device-1;device_key_id=key-1 invalid:forbidden_field
18 authenticated-browser-empty-role 2 browser_authenticated_session username=alice;provider=local;roles=;role=user invalid:invalid_session
19 authenticated-browser-long-role 2 browser_authenticated_session username=alice;provider=local;roles=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;role=user invalid:invalid_session
20 authenticated-browser-role-64-utf8-bytes 2 browser_authenticated_session username=alice;provider=local;roles=éééééééééééééééééééééééééééééééé;role=user valid
21 authenticated-browser-role-66-utf8-bytes 2 browser_authenticated_session username=alice;provider=local;roles=ééééééééééééééééééééééééééééééééé;role=user invalid:invalid_session
22 authenticated-native-session 2 native_authenticated_session username=alice;provider=local;roles=user;role=user;client_device_id=device-1;device_key_id=key-1 valid
23 authenticated-native-missing-identity 2 native_authenticated_session username=alice;provider=local;roles=user;role=user invalid:required
24 authenticated-partial-native-session 2 native_authenticated_session username=alice;provider=local;roles=user;role=user;client_device_id=device-1 invalid:required
25 authenticated-native-empty-role 2 native_authenticated_session username=alice;provider=local;roles=;role=user;client_device_id=device-1;device_key_id=key-1 invalid:invalid_session
26 authenticated-native-long-role 2 native_authenticated_session username=alice;provider=local;roles=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;role=user;client_device_id=device-1;device_key_id=key-1 invalid:invalid_session
27 authenticated-native-role-64-utf8-bytes 2 native_authenticated_session username=alice;provider=local;roles=éééééééééééééééééééééééééééééééé;role=user;client_device_id=device-1;device_key_id=key-1 valid
28 authenticated-native-role-66-utf8-bytes 2 native_authenticated_session username=alice;provider=local;roles=ééééééééééééééééééééééééééééééééé;role=user;client_device_id=device-1;device_key_id=key-1 invalid:invalid_session
29 native-tunnel-credential 2 native_tunnel_credential client_device_id=device-1;device_key_id=key-1;certificate_chain_pem=certificate;trust_bundle_pem=trust;expires_at=2099-01-01T00:00:00Z valid
30 native-tunnel-credential-offset-time 2 native_tunnel_credential client_device_id=device-1;device_key_id=key-1;certificate_chain_pem=certificate;trust_bundle_pem=trust;expires_at=2099-01-01T00:00:00+00:00 invalid:invalid_credential
31 native-tunnel-credential-noncanonical-fraction 2 native_tunnel_credential client_device_id=device-1;device_key_id=key-1;certificate_chain_pem=certificate;trust_bundle_pem=trust;expires_at=2099-01-01T00:00:00.100Z invalid:invalid_credential
+2
View File
@@ -0,0 +1,2 @@
id version kind input expected
device-proof-canonical 1 device_proof_transcript server_id=00112233445566778899aabbccddeeff;principal_id=102132435465768798a9bacbdcedfe0f;device_id=ffeeddccbbaa99887766554433221100;challenge=000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f;expiry_unix_ms=1700000000123 76657273657664692d6465766963652d70726f6f662d763100112233445566778899aabbccddeeff102132435465768798a9bacbdcedfe0fffeeddccbbaa99887766554433221100000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f0000018bcfe5687b
1 id version kind input expected
2 device-proof-canonical 1 device_proof_transcript server_id=00112233445566778899aabbccddeeff;principal_id=102132435465768798a9bacbdcedfe0f;device_id=ffeeddccbbaa99887766554433221100;challenge=000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f;expiry_unix_ms=1700000000123 76657273657664692d6465766963652d70726f6f662d763100112233445566778899aabbccddeeff102132435465768798a9bacbdcedfe0fffeeddccbbaa99887766554433221100000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f0000018bcfe5687b
+8 -8
View File
@@ -1,9 +1,9 @@
id version kind input expected
tunnel-current 1 tunnel offered=1;feature=control.v1 valid
tunnel-n-minus-1 0 tunnel offered=0;feature=control.v1 valid
tunnel-n-minus-2 -1 tunnel offered=-1;feature=control.v1 valid
tunnel-display-request 1 tunnel offered=1;feature=display.request.v1 valid
tunnel-absolute-input 1 tunnel offered=1;feature=input.absolute.v1 valid
tunnel-scroll-input 1 tunnel offered=1;feature=input.scroll.v1 valid
tunnel-unsupported 1 tunnel offered=2;feature=control.v1 invalid:unsupported_version
tunnel-no-control 1 tunnel offered=1;feature=media.video invalid:unsupported_feature
tunnel-current 2 tunnel offered=2;feature=control.v2 valid
tunnel-n-minus-1 1 tunnel offered=1;feature=control.v1 valid
tunnel-n-minus-2 0 tunnel offered=0;feature=control.v1 valid
tunnel-display-request 2 tunnel offered=2;feature=display.request.v1 valid
tunnel-absolute-input 2 tunnel offered=2;feature=input.absolute.v1 valid
tunnel-scroll-input 2 tunnel offered=2;feature=input.scroll.v1 valid
tunnel-unsupported 2 tunnel offered=3;feature=control.v2 invalid:unsupported_version
tunnel-no-control 2 tunnel offered=2;feature=media.video invalid:unsupported_feature
1 id version kind input expected
2 tunnel-current 1 2 tunnel offered=1;feature=control.v1 offered=2;feature=control.v2 valid
3 tunnel-n-minus-1 0 1 tunnel offered=0;feature=control.v1 offered=1;feature=control.v1 valid
4 tunnel-n-minus-2 -1 0 tunnel offered=-1;feature=control.v1 offered=0;feature=control.v1 valid
5 tunnel-display-request 1 2 tunnel offered=1;feature=display.request.v1 offered=2;feature=display.request.v1 valid
6 tunnel-absolute-input 1 2 tunnel offered=1;feature=input.absolute.v1 offered=2;feature=input.absolute.v1 valid
7 tunnel-scroll-input 1 2 tunnel offered=1;feature=input.scroll.v1 offered=2;feature=input.scroll.v1 valid
8 tunnel-unsupported 1 2 tunnel offered=2;feature=control.v1 offered=3;feature=control.v2 invalid:unsupported_version
9 tunnel-no-control 1 2 tunnel offered=1;feature=media.video 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": {
"id": "gateway-1",
"addresses": ["gateway.control.test:443"],
"public_identity": "gateway-1"
"public_identity": "gateway.control.test"
},
"tunnel": {
"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": {}
}
+2 -1
View File
@@ -4,11 +4,12 @@
"fixtures/conformance/control-v1.tsv",
"fixtures/conformance/datagram-v1.tsv",
"fixtures/conformance/datagram-v2.tsv",
"fixtures/conformance/device-proof-v1.tsv",
"fixtures/conformance/events-v1.tsv",
"fixtures/conformance/gateway-clipboard-audit-v1.tsv",
"fixtures/conformance/gateway-clipboard-v1.tsv",
"fixtures/conformance/gateway-input-feedback-v1.tsv",
"fixtures/conformance/tunnel-v1.tsv"
],
"corpus_sha256": "4f8bf520d4f61588235b00776b21202ff701b20d6d11b461037b911c2117852b"
"corpus_sha256": "6d2ce3a855b2fa45733a5f7b5b4c2e68448cceed5dfbca535ec81fe8cf230b30"
}
@@ -0,0 +1,6 @@
{
"username": "alice",
"provider": "local",
"roles": ["user"],
"role": "user"
}
@@ -0,0 +1,10 @@
{
"username": "alice",
"provider": "local",
"roles": ["user"],
"role": "user",
"native_identity": {
"client_device_id": "device-1",
"device_key_id": "key-1"
}
}
+1 -1
View File
@@ -6,7 +6,7 @@
"gateway": {
"id": "gateway-1",
"addresses": ["gateway.control.test:443"],
"public_identity": "gateway-1"
"public_identity": "gateway.control.test"
},
"tunnel": {
"versions": ["verse-gateway-v1/1"],
@@ -0,0 +1,7 @@
{
"client_device_id": "device-1",
"device_key_id": "key-1",
"certificate_chain_pem": "-----BEGIN CERTIFICATE-----\nfixture\n-----END CERTIFICATE-----",
"trust_bundle_pem": "-----BEGIN CERTIFICATE-----\nfixture-ca\n-----END CERTIFICATE-----",
"expires_at": "2099-01-01T00:00:00Z"
}
+6
View File
@@ -0,0 +1,6 @@
{
"client_device_id": "device-1",
"device_key_id": "key-1",
"pool_id": "pool-1",
"idempotency_key": "request-1"
}
+625 -19
View File
@@ -4,6 +4,7 @@ package protocol
import (
"bytes"
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
@@ -13,11 +14,11 @@ import (
"time"
)
const SchemaSHA256 = "b2bb0a8ac8ef56dbc0e1443eeb5b3028be9e71ec2f5fd8e73928d71b7cd9340c"
const SchemaSHA256 = "762d009c3d25d80c3850d975e45f7a6b3fd8adf5c93c8fa7dd11dfa993f8bbb1"
const ProtocolVersion = "1.0.0"
const CurrentWireVersion = "1"
const NMinus1WireVersion = "0"
const NMinus2WireVersion = "-1"
const CurrentWireVersion = "2"
const NMinus1WireVersion = "1"
const NMinus2WireVersion = "0"
type FieldViolation struct {
Field string `json:"field"`
@@ -69,6 +70,13 @@ type BrokerSession struct {
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 {
Transport string `json:"transport"`
Framing string `json:"framing"`
@@ -89,6 +97,16 @@ type ChannelFrame struct {
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 {
ClientToProviderEnabled bool `json:"client_to_provider_enabled"`
ProviderToClientEnabled bool `json:"provider_to_client_enabled"`
@@ -281,6 +299,14 @@ type ManifestTunnel struct {
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 {
DeviceID string `json:"device_id,omitempty"`
FamilyID string `json:"family_id"`
@@ -290,6 +316,19 @@ type NativeCredential struct {
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 {
Limit int64 `json:"limit"`
NextCursor string `json:"next_cursor"`
@@ -392,12 +431,11 @@ type SessionAuthority struct {
}
type SessionRequest struct {
ClientDeviceID string `json:"client_device_id"`
DeviceKeyID string `json:"device_key_id"`
PoolID string `json:"pool_id"`
IdempotencyKey string `json:"idempotency_key"`
PolicySnapshot AllocationPolicy `json:"policy_snapshot"`
RequestedDisplayMode *DisplayMode `json:"requested_display_mode,omitempty"`
ClientDeviceID string `json:"client_device_id"`
DeviceKeyID string `json:"device_key_id"`
PoolID string `json:"pool_id"`
IdempotencyKey string `json:"idempotency_key"`
RequestedDisplayMode *DisplayMode `json:"requested_display_mode,omitempty"`
}
type StableError struct {
@@ -863,6 +901,105 @@ func EncodeBrokerSession(value BrokerSession) ([]byte, error) {
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 {
var violations []FieldViolation
if v.Transport == "" {
@@ -1106,6 +1243,122 @@ func EncodeChannelFrame(value ChannelFrame) ([]byte, error) {
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 {
var violations []FieldViolation
if v.MaxTextBytes == 0 {
@@ -2689,6 +2942,16 @@ func (v GatewayRegistration) Validate() error {
if len(v.Features) > 64 {
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{}) {
violations = append(violations, FieldViolation{Field: "capabilities", Code: "required"})
}
@@ -3231,6 +3494,16 @@ func (v ManifestGateway) Validate() error {
if len(v.Addresses) > 4 {
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 == "" {
violations = append(violations, FieldViolation{Field: "public_identity", Code: "required"})
}
@@ -3371,12 +3644,32 @@ func (v ManifestTunnel) Validate() error {
if len(v.Versions) > 4 {
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 {
violations = append(violations, FieldViolation{Field: "features", Code: "required"})
}
if len(v.Features) > 32 {
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 {
return ValidationError{Violations: violations}
}
@@ -3423,6 +3716,114 @@ func EncodeManifestTunnel(value ManifestTunnel) ([]byte, error) {
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 {
var violations []FieldViolation
if len(v.DeviceID) > 128 {
@@ -3526,6 +3927,176 @@ func EncodeNativeCredential(value NativeCredential) ([]byte, error) {
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 {
var violations []FieldViolation
if v.Limit == 0 {
@@ -3875,6 +4446,16 @@ func (v ProviderState) Validate() error {
if len(v.Channels) > 8 {
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 {
return ValidationError{Violations: violations}
}
@@ -4761,12 +5342,6 @@ func (v SessionRequest) Validate() error {
if len(v.IdempotencyKey) > 256 {
violations = append(violations, FieldViolation{Field: "idempotency_key", Code: "max_length"})
}
if reflect.DeepEqual(v.PolicySnapshot, AllocationPolicy{}) {
violations = append(violations, FieldViolation{Field: "policy_snapshot", Code: "required"})
}
if err := v.PolicySnapshot.Validate(); err != nil {
violations = append(violations, FieldViolation{Field: "policy_snapshot", Code: "invalid_object"})
}
if v.RequestedDisplayMode != nil {
if err := v.RequestedDisplayMode.Validate(); err != nil {
violations = append(violations, FieldViolation{Field: "requested_display_mode", Code: "invalid_object"})
@@ -4796,9 +5371,6 @@ func DecodeSessionRequest(data []byte) (SessionRequest, error) {
if raw, ok := fields["idempotency_key"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
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")) {
return value, ValidationError{Violations: []FieldViolation{{Field: "pool_id", Code: "required"}}}
}
@@ -5057,12 +5629,22 @@ func (v VersionNegotiation) Validate() error {
if len(v.SupportedVersions) > 3 {
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 {
violations = append(violations, FieldViolation{Field: "features", Code: "required"})
}
if len(v.Features) > 64 {
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 {
return ValidationError{Violations: violations}
}
@@ -5109,6 +5691,30 @@ func EncodeVersionNegotiation(value VersionNegotiation) ([]byte, error) {
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")
func IntersectCapabilityProfiles(profiles ...CapabilityProfile) (CapabilityProfile, error) {
+7 -7
View File
@@ -1,18 +1,18 @@
{
"compatibility": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"current": "1",
"current": "2",
"datagram_registry": "registries/datagrams.json",
"feature_registry": "registries/features.json",
"n_minus_1": "0",
"n_minus_2": "-1",
"n_minus_1": "1",
"n_minus_2": "0",
"protocol": "versevdi-control",
"unsupported": [
"-2",
"2"
"-1",
"3"
]
},
"generator_sha256": "992235a56d3467313148f86e47931f247591e4c8de737b55ac9c9eee35725fc5",
"generator_sha256": "00c1905fc611ca9e226cd90da761b48b8e203734b10542befea397a30082d360",
"protocol_version": "1.0.0",
"schema_sha256": "b2bb0a8ac8ef56dbc0e1443eeb5b3028be9e71ec2f5fd8e73928d71b7cd9340c"
"schema_sha256": "762d009c3d25d80c3850d975e45f7a6b3fd8adf5c93c8fa7dd11dfa993f8bbb1"
}
Binary file not shown.
+237 -9
View File
@@ -1,9 +1,9 @@
// Code generated by tools/generate.py; DO NOT EDIT.
#![allow(non_snake_case)]
pub const SCHEMA_SHA256: &str = "b2bb0a8ac8ef56dbc0e1443eeb5b3028be9e71ec2f5fd8e73928d71b7cd9340c";
pub const CURRENT_WIRE_VERSION: &str = "1";
pub const N_MINUS_1_WIRE_VERSION: &str = "0";
pub const N_MINUS_2_WIRE_VERSION: &str = "-1";
pub const SCHEMA_SHA256: &str = "762d009c3d25d80c3850d975e45f7a6b3fd8adf5c93c8fa7dd11dfa993f8bbb1";
pub const CURRENT_WIRE_VERSION: &str = "2";
pub const N_MINUS_1_WIRE_VERSION: &str = "1";
pub const N_MINUS_2_WIRE_VERSION: &str = "0";
pub type JsonObject = std::collections::BTreeMap<String, String>;
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -30,6 +30,19 @@ fn valid_base64_url(value: &str) -> bool {
_ => 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)]
pub struct AllocationPolicy {
@@ -165,6 +178,7 @@ impl BrokerSession {
self.policySnapshot.validate().map_err(|_| ValidationError::new("policy_snapshot", "invalid_object"))?;
if let Some(value) = &self.reconnectDeadline {
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 value.len() > 64 { return Err(ValidationError::new("outcome", "max_length")); }
@@ -182,8 +196,10 @@ impl BrokerSession {
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.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 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 let Some(value) = &self.requestedDisplayMode {
@@ -213,6 +229,40 @@ impl BrokerSession {
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)]
pub struct CapabilityProfile {
transport: String,
@@ -303,6 +353,49 @@ impl ChannelFrame {
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)]
pub struct ClipboardPolicy {
clientToProviderEnabled: bool,
@@ -428,6 +521,7 @@ impl DeviceChallenge {
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.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.signatureFormat != "ed25519-domain-separated-v1" { return Err(ValidationError::new("signature_format", "invalid_value")); }
Ok(())
@@ -636,6 +730,7 @@ impl EventEnvelope {
if self.version < 1 { return Err(ValidationError::new("version", "minimum")); }
self.resource.validate().map_err(|_| ValidationError::new("resource", "invalid_object"))?;
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() && 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")); }
@@ -789,6 +884,7 @@ impl GatewayDrain {
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.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(())
}
pub fn version(&self) -> &String { &self.version }
@@ -823,6 +919,7 @@ impl GatewayHeartbeat {
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.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 > 1000000 { return Err(ValidationError::new("active_connections", "maximum")); }
if self.egressKbps < 0 { return Err(ValidationError::new("egress_kbps", "minimum")); }
@@ -893,6 +990,8 @@ impl GatewayRegistration {
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.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"))?;
if self.protocolMinVersion > self.protocolMaxVersion { return Err(ValidationError::new("protocol_version", "invalid_order")); }
Ok(())
@@ -1016,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.len() > 256 { return Err(ValidationError::new("opaque_value", "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() && self.audience.len() < 1 { return Err(ValidationError::new("audience", "min_length")); }
if self.audience.len() > 128 { return Err(ValidationError::new("audience", "max_length")); }
@@ -1103,6 +1203,8 @@ impl ManifestGateway {
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() > 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() && 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")); }
@@ -1156,13 +1258,54 @@ impl ManifestTunnel {
pub fn validate(&self) -> Result<(), ValidationError> {
if self.versions.len() < 1 { return Err(ValidationError::new("versions", "min_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")); }
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(())
}
pub fn versions(&self) -> &Vec<String> { &self.versions }
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)]
pub struct NativeCredential {
deviceId: Option<String>,
@@ -1193,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.len() > 256 { return Err(ValidationError::new("refresh_token", "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 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(())
}
@@ -1206,6 +1351,70 @@ impl NativeCredential {
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)]
pub struct PageInfo {
limit: i64,
@@ -1268,6 +1477,7 @@ impl ProviderSessionWork {
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.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.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")); }
@@ -1348,6 +1558,8 @@ impl ProviderState {
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.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(())
}
pub fn version(&self) -> &String { &self.version }
@@ -1414,6 +1626,7 @@ impl ReauthGrant {
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.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 token(&self) -> &String { &self.token }
@@ -1630,6 +1843,7 @@ impl SessionAuthority {
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"))?;
if self.providerProfile != "apollo" { return Err(ValidationError::new("provider_profile", "invalid_value")); }
if self.providerIdentity.is_empty() { return Err(ValidationError::new("provider_identity", "required")); }
@@ -1654,13 +1868,12 @@ pub struct SessionRequest {
deviceKeyId: String,
poolId: String,
idempotencyKey: String,
policySnapshot: AllocationPolicy,
requestedDisplayMode: Option<DisplayMode>,
}
impl SessionRequest {
pub fn new(clientDeviceId: String, deviceKeyId: String, poolId: String, idempotencyKey: String, policySnapshot: AllocationPolicy, requestedDisplayMode: Option<DisplayMode>) -> Result<Self, ValidationError> {
let value = Self { clientDeviceId, deviceKeyId, poolId, idempotencyKey, policySnapshot, requestedDisplayMode };
pub fn new(clientDeviceId: String, deviceKeyId: String, poolId: String, idempotencyKey: String, requestedDisplayMode: Option<DisplayMode>) -> Result<Self, ValidationError> {
let value = Self { clientDeviceId, deviceKeyId, poolId, idempotencyKey, requestedDisplayMode };
value.validate()?;
Ok(value)
}
@@ -1677,7 +1890,6 @@ impl SessionRequest {
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.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"))?;
}
@@ -1687,7 +1899,6 @@ impl SessionRequest {
pub fn deviceKeyId(&self) -> &String { &self.deviceKeyId }
pub fn poolId(&self) -> &String { &self.poolId }
pub fn idempotencyKey(&self) -> &String { &self.idempotencyKey }
pub fn policySnapshot(&self) -> &AllocationPolicy { &self.policySnapshot }
pub fn requestedDisplayMode(&self) -> &Option<DisplayMode> { &self.requestedDisplayMode }
}
@@ -1799,13 +2010,30 @@ impl VersionNegotiation {
pub fn validate(&self) -> Result<(), ValidationError> {
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")); }
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")); }
for item in self.features.iter() { if item.as_bytes().len() > 64 { return Err(ValidationError::new("features", "max_item_length")); } }
Ok(())
}
pub fn supportedVersions(&self) -> &Vec<String> { &self.supportedVersions }
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> {
let mut selected = profiles.first().ok_or_else(|| ValidationError::new("capabilities", "no_overlap"))?.clone();
selected.validate().map_err(|_| ValidationError::new("capabilities", "no_overlap"))?;
+299 -23
View File
@@ -1,10 +1,10 @@
// Code generated by tools/generate.py; DO NOT EDIT.
import Foundation
public typealias JSONObject = [String: String]
public let schemaSHA256 = "b2bb0a8ac8ef56dbc0e1443eeb5b3028be9e71ec2f5fd8e73928d71b7cd9340c"
public let currentWireVersion = "1"
public let nMinus1WireVersion = "0"
public let nMinus2WireVersion = "-1"
public let schemaSHA256 = "762d009c3d25d80c3850d975e45f7a6b3fd8adf5c93c8fa7dd11dfa993f8bbb1"
public let currentWireVersion = "2"
public let nMinus1WireVersion = "1"
public let nMinus2WireVersion = "0"
public struct ContractValidationError: Error, Equatable { public let field: String; public let code: String }
private struct AnyCodingKey: CodingKey { let stringValue: String; let intValue: Int?; init?(stringValue: String) { self.stringValue = stringValue; self.intValue = nil }; init?(intValue: Int) { self.stringValue = String(intValue); self.intValue = intValue } }
private func validBase64URL(_ value: String) -> Bool {
@@ -16,6 +16,23 @@ private func validBase64URL(_ value: String) -> Bool {
guard let decoded = Data(base64Encoded: standard) else { return false }
return decoded.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "") == value
}
private func validRFC3339UTC(_ value: String) -> Bool {
let bytes = Array(value.utf8)
guard (20...30).contains(bytes.count), bytes[4] == 45, bytes[7] == 45, bytes[10] == 84, bytes[13] == 58, bytes[16] == 58, bytes.last == 90 else { return false }
func digits(_ range: Range<Int>) -> Int? {
var result = 0
for index in range { guard bytes[index] >= 48 && bytes[index] <= 57 else { return nil }; result = result * 10 + Int(bytes[index] - 48) }
return result
}
guard let year = digits(0..<4), let month = digits(5..<7), let day = digits(8..<10), let hour = digits(11..<13), let minute = digits(14..<16), let second = digits(17..<19), hour <= 23, minute <= 59, second <= 59 else { return false }
let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
let days: Int
switch month { case 1, 3, 5, 7, 8, 10, 12: days = 31; case 4, 6, 9, 11: days = 30; case 2: days = leap ? 29 : 28; default: return false }
guard day > 0 && day <= days else { return false }
if bytes.count == 20 { return true }
let fraction = bytes[20..<(bytes.count - 1)]
return bytes[19] == 46 && !fraction.isEmpty && fraction.count <= 9 && fraction.allSatisfy { $0 >= 48 && $0 <= 57 } && fraction.last != 48
}
public struct AllocationPolicy: Codable, Equatable {
public let minimumKbps: Int64
@@ -217,7 +234,7 @@ public struct BrokerSession: Codable, Equatable {
try self.policySnapshot.validate()
if let value = self.reconnectDeadline {
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 value.utf8.count > 64 { throw ContractValidationError(field: "outcome", code: "max_length") }
@@ -235,10 +252,10 @@ 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.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 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 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 let value = self.requestedDisplayMode {
@@ -253,6 +270,51 @@ public struct BrokerSession: Codable, Equatable {
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 func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }
}
public struct CapabilityProfile: Codable, Equatable {
public let transport: String
public let framing: String
@@ -371,6 +433,63 @@ public struct ChannelFrame: Codable, Equatable {
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 let clientToProviderEnabled: Bool
public let providerToClientEnabled: Bool
@@ -551,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.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 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.signatureFormat != "ed25519-domain-separated-v1" { throw ContractValidationError(field: "signature_format", code: "invalid_value") }
}
@@ -831,7 +950,7 @@ public struct EventEnvelope: Codable, Equatable {
if self.version < 1 { throw ContractValidationError(field: "version", code: "minimum") }
try self.resource.validate()
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 && 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") }
@@ -1036,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.utf8.count > 256 { throw ContractValidationError(field: "reason", 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) }
@@ -1089,7 +1208,7 @@ public struct GatewayHeartbeat: Codable, Equatable {
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.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 > 1000000 { throw ContractValidationError(field: "active_connections", code: "maximum") }
if self.egressKbps < 0 { throw ContractValidationError(field: "egress_kbps", code: "minimum") }
@@ -1185,6 +1304,8 @@ public struct GatewayRegistration: Codable, Equatable {
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.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()
if protocolMinVersion > protocolMaxVersion { throw ContractValidationError(field: "protocol_version", code: "invalid_order") }
}
@@ -1333,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.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 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 && 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") }
@@ -1451,6 +1572,8 @@ public struct ManifestGateway: Codable, Equatable {
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 > 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 && 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") }
@@ -1522,7 +1645,60 @@ public struct ManifestTunnel: Codable, Equatable {
public func validate() throws {
if self.versions.count < 1 { throw ContractValidationError(field: "versions", code: "min_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") }
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) }
@@ -1576,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.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 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 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") }
}
}
@@ -1587,6 +1763,91 @@ public struct NativeCredential: Codable, Equatable {
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 let limit: Int64
public let nextCursor: String
@@ -1703,7 +1964,7 @@ public struct ProviderSessionWork: Codable, Equatable {
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.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.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") }
@@ -1781,6 +2042,8 @@ public struct ProviderState: Codable, Equatable {
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 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) }
@@ -1868,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.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 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) }
@@ -2169,7 +2432,7 @@ public struct SessionAuthority: Codable, Equatable {
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 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()
if !["apollo"].contains(self.providerProfile) { throw ContractValidationError(field: "provider_profile", code: "invalid_value") }
if self.providerIdentity.isEmpty { throw ContractValidationError(field: "provider_identity", code: "required") }
@@ -2186,23 +2449,20 @@ public struct SessionRequest: Codable, Equatable {
public let deviceKeyId: String
public let poolId: String
public let idempotencyKey: String
public let policySnapshot: AllocationPolicy
public let requestedDisplayMode: DisplayMode?
enum CodingKeys: String, CodingKey {
case clientDeviceId = "client_device_id"
case deviceKeyId = "device_key_id"
case poolId = "pool_id"
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, requestedDisplayMode: DisplayMode?) throws {
public init(clientDeviceId: String, deviceKeyId: String, poolId: String, idempotencyKey: String, requestedDisplayMode: DisplayMode?) throws {
self.clientDeviceId = clientDeviceId
self.deviceKeyId = deviceKeyId
self.poolId = poolId
self.idempotencyKey = idempotencyKey
self.policySnapshot = policySnapshot
self.requestedDisplayMode = requestedDisplayMode
try validate()
}
@@ -2211,7 +2471,7 @@ public struct SessionRequest: Codable, Equatable {
let all = try decoder.container(keyedBy: AnyCodingKey.self)
for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") }
let c = try decoder.container(keyedBy: CodingKeys.self)
try self.init(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), requestedDisplayMode: try c.contains(.requestedDisplayMode) ? c.decode(DisplayMode.self, forKey: .requestedDisplayMode) : nil)
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 {
@@ -2227,7 +2487,6 @@ public struct SessionRequest: Codable, Equatable {
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.utf8.count > 256 { throw ContractValidationError(field: "idempotency_key", code: "max_length") }
try self.policySnapshot.validate()
if let value = self.requestedDisplayMode {
try value.validate()
}
@@ -2372,13 +2631,30 @@ public struct VersionNegotiation: Codable, Equatable {
public func validate() throws {
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") }
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") }
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 func deviceRegistrationProofTranscript(serverID: Data, principalID: Data, deviceID: Data, challenge: Data, expiryUnixMilliseconds: Int64) throws -> Data {
for (field, value, length) in [("server_id", serverID, 16), ("principal_id", principalID, 16), ("device_id", deviceID, 16), ("challenge", challenge, 32)] {
if value.count != length { throw ContractValidationError(field: field, code: "invalid_length") }
}
if expiryUnixMilliseconds < 0 { throw ContractValidationError(field: "expiry_unix_milliseconds", code: "minimum") }
var transcript = Data("versevdi-device-proof-v1".utf8)
transcript.append(serverID)
transcript.append(principalID)
transcript.append(deviceID)
transcript.append(challenge)
var expiry = UInt64(expiryUnixMilliseconds).bigEndian
Swift.withUnsafeBytes(of: &expiry) { transcript.append(contentsOf: $0) }
return transcript
}
public extension TunnelAdmissionRequest {
func deviceAdmissionTranscript() -> Data {
var fields = [sessionId, gatewayId, audience, grant, String(reconnectSequence), clientNonce, capabilities.transport, capabilities.framing, capabilities.media, capabilities.audio, capabilities.sourceRateControl, String(capabilities.clientDecode.count)]
+55 -19
View File
@@ -2,7 +2,7 @@ openapi: 3.1.0
info:
title: VerseVDI control contract
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:
- url: /
tags:
@@ -23,18 +23,11 @@ paths:
$ref: ../schemas/control-v1.schema.json#/$defs/LoginRequest
responses:
'200':
description: Browser session established through secure cookies.
description: Browser session established through secure cookies; native identity is structurally forbidden.
content:
application/json:
schema:
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, maxLength: 64}}
role: {type: string, enum: [user, admin]}
$ref: ../schemas/control-v1.schema.json#/$defs/BrowserAuthenticatedSession
'400': {$ref: '#/components/responses/InvalidRequest'}
'401': {$ref: '#/components/responses/Unauthorized'}
'503': {$ref: '#/components/responses/Unavailable'}
@@ -66,18 +59,32 @@ paths:
- nativeBearer: []
responses:
'200':
description: Authenticated subject summary.
description: Route-context-specific authenticated subject summary. Browser responses forbid native identity; native bearer responses require it.
content:
application/json:
schema:
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, maxLength: 64}}
role: {type: string, enum: [user, admin]}
oneOf:
- $ref: ../schemas/control-v1.schema.json#/$defs/BrowserAuthenticatedSession
- $ref: ../schemas/control-v1.schema.json#/$defs/NativeAuthenticatedSession
'401': {$ref: '#/components/responses/Unauthorized'}
/api/v1/auth/tunnel-credentials:
post:
tags: [authentication]
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'}
/api/v1/auth/reauth:
post:
@@ -85,6 +92,8 @@ paths:
operationId: issueReauthenticationGrant
security:
- browserSession: []
browserCsrfCookie: []
browserCsrfHeader: []
requestBody:
required: true
content:
@@ -106,6 +115,8 @@ paths:
operationId: logoutSession
security:
- browserSession: []
browserCsrfCookie: []
browserCsrfHeader: []
- nativeBearer: []
responses:
'204': {description: Session revoked and browser cookies cleared.}
@@ -116,6 +127,8 @@ paths:
operationId: registerDevice
security:
- browserSession: []
browserCsrfCookie: []
browserCsrfHeader: []
requestBody:
required: true
content:
@@ -137,6 +150,8 @@ paths:
operationId: proveDevice
security:
- browserSession: []
browserCsrfCookie: []
browserCsrfHeader: []
parameters:
- $ref: '#/components/parameters/DeviceID'
requestBody:
@@ -160,6 +175,8 @@ paths:
operationId: revokeDevice
security:
- browserSession: []
browserCsrfCookie: []
browserCsrfHeader: []
parameters:
- $ref: '#/components/parameters/DeviceID'
responses:
@@ -189,8 +206,11 @@ paths:
post:
tags: [broker]
operationId: requestBrokerSession
description: Control wire version 2 endpoint. Legacy version-1 SessionRequest payloads containing client-supplied policy_snapshot are rejected.
security:
- browserSession: []
browserCsrfCookie: []
browserCsrfHeader: []
- nativeBearer: []
parameters:
- $ref: '#/components/parameters/IdempotencyKey'
@@ -241,6 +261,8 @@ paths:
operationId: allocateBrokerSession
security:
- browserSession: []
browserCsrfCookie: []
browserCsrfHeader: []
- nativeBearer: []
parameters:
- $ref: '#/components/parameters/SessionID'
@@ -266,6 +288,8 @@ paths:
operationId: reconnectBrokerSession
security:
- browserSession: []
browserCsrfCookie: []
browserCsrfHeader: []
- nativeBearer: []
parameters:
- $ref: '#/components/parameters/SessionID'
@@ -292,6 +316,8 @@ paths:
operationId: cancelBrokerSession
security:
- browserSession: []
browserCsrfCookie: []
browserCsrfHeader: []
- nativeBearer: []
parameters:
- $ref: '#/components/parameters/SessionID'
@@ -345,6 +371,16 @@ components:
type: apiKey
in: cookie
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:
type: http
scheme: bearer
@@ -12,6 +12,6 @@
## 3. Freeze
- [ ] 3.1 Commit the verified source and generated bindings as one unsigned capability-sized change.
- [ ] 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.
- [ ] 3.3 Reconcile and archive this OpenSpec change after the immutable candidate is frozen.
- [x] 3.1 Commit the verified source and generated bindings as one unsigned capability-sized change.
- [x] 3.2 Verify the next Phase 3D Protocol RC name is unused locally and remotely, then create one local immutable candidate tag without publishing it.
- [x] 3.3 Reconcile and archive this OpenSpec change after the immutable candidate is frozen.
@@ -0,0 +1,4 @@
schema: spec-driven
created: 2026-08-12
goal: Split client-facing session authority from the provider-bearing
Server-to-gateway authority for the coordinated RC4 hard cut.
@@ -0,0 +1,41 @@
## Context
RC3 uses one provider-bearing `SessionAuthority` for both the authenticated Server-to-gateway control plane and the gateway-to-client acknowledgement. Provider profile and identity are valid inputs to gateway provider work and release, but they are forbidden at the client boundary. Existing strict RC3 clients require those fields, so changing the client shape is intentionally incompatible.
## Goals / Non-Goals
**Goals:**
- Make provider disclosure structurally impossible in the client-facing authority type.
- Preserve the provider-bound Server-to-gateway admission, work, release, and cleanup contract.
- Produce strict, matching JSON Schema, Protobuf, Go, Rust, and Swift contracts.
**Non-Goals:**
- Supporting mixed RC3/RC4 gateway and client pairings.
- Changing `SessionAuthority`, `ProviderSessionWork`, `VERSION`, or global compatibility history.
- Adding response negotiation, optional provider fields, or permissive decoding.
## Decisions
1. Add `ClientSessionAuthority` with exactly `version`, `session_id`, `gateway_id`, `audience`, `reconnect_sequence`, `expires_at`, and `capabilities`. Reusing the common validation bounds keeps the new acknowledgement session-bound without representing provider data.
2. Keep the existing provider-bearing `SessionAuthority` unchanged for Server-to-gateway operations. Deleting its provider fields would broaden the security-sensitive change into Server admission and provider-work validation.
3. Treat RC4 as a coordinated hard cut. A dual decoder would still accept the forbidden RC3 shape and is unnecessary for an unreleased candidate.
4. Use the existing generator unchanged. The JSON Schema definition is sufficient to generate strict Go, Rust, and Swift types; the matching Protobuf message uses fields 1 through 7.
## Risks / Trade-offs
- [RC3 and RC4 clients are not wire-compatible] → Pin and qualify Server, gateway, and client as one exact RC4 set; retain RC3 as an immutable rollback set.
- [A future gateway could serialize the wrong authority type] → Consumer gateway tests must capture the raw acknowledgement and require `ClientSessionAuthority` with no provider-bearing keys.
- [Strict decoding rejects future additive fields] → Version a future client authority explicitly instead of weakening this v1 decoder.
## Migration Plan
1. Publish the verified immutable Protocol RC4 tag.
2. Repin Data, Server, and macOS to the exact RC4 commit.
3. Change gateway egress and client decoders together, then qualify the exact all-RC4 set.
4. Roll back only as the complete immutable RC3 set; do not retag or mix candidates.
## Open Questions
None for this pre-release hard cut. Evidence of deployed RC3 coexistence would require a separate negotiated-version design and blocks this migration model.
@@ -0,0 +1,23 @@
## Why
The gateway currently serializes the provider-bearing Server-to-gateway `SessionAuthority` to clients, crossing provider identity into a client trust boundary that forbids it. RC4 must make that boundary structural before the pre-release client set is qualified.
## What Changes
- Add a strict provider-free `ClientSessionAuthority` with the seven session, gateway, audience, reconnect, expiry, and capability fields shared with `SessionAuthority`.
- Keep `SessionAuthority` and `ProviderSessionWork` unchanged for the authenticated Server-to-gateway control plane.
- **BREAKING** Replace the gateway-to-client RC3 response shape with `ClientSessionAuthority` as a coordinated RC4 hard cut; no mixed RC3/RC4 compatibility is claimed.
## Capabilities
### New Capabilities
- `gateway-transport-and-admission`: Defines the distinct client-facing authority and its provider-free gateway admission boundary.
### Modified Capabilities
None.
## Impact
Protocol JSON Schema, tunnel Protobuf, generated Go/Rust/Swift bindings, and consumer Protocol pins advance together to `v1.0.0-phase3d-macos-rc.4`. `VERSION`, global compatibility history, and the Server-to-gateway provider authority remain unchanged.
@@ -0,0 +1,30 @@
## ADDED Requirements
### Requirement: Client-facing authority is provider-free
The gateway-to-client acknowledgement SHALL use `ClientSessionAuthority` version `"1"` containing exactly `version`, `session_id`, `gateway_id`, `audience`, `reconnect_sequence`, `expires_at`, and `capabilities`. The contract SHALL reject missing required fields, unknown fields including provider identities and routes, invalid or noncanonical expiry timestamps, and trailing JSON values.
#### Scenario: Gateway acknowledges an admitted client
- **WHEN** provider work succeeds and gateway and client capabilities intersect
- **THEN** the gateway returns a valid `ClientSessionAuthority` containing the selected capabilities and no provider-bearing field
#### Scenario: Client receives provider-bearing authority
- **WHEN** a client authority payload contains `provider_profile`, `provider_identity`, a provider route, or any unknown key
- **THEN** the strict client authority decoder rejects the payload
#### Scenario: Client receives incomplete or malformed authority
- **WHEN** a client authority omits any required binding, has an invalid expiry, or is followed by another JSON value
- **THEN** the strict client authority decoder rejects the payload
### Requirement: Server-to-gateway authority remains provider-bound
The authenticated Server-to-gateway control plane SHALL continue to use the existing provider-bearing `SessionAuthority` for admission, provider work, release, and cleanup. `SessionAuthority` and `ProviderSessionWork` fields and semantics MUST remain unchanged by this change.
#### Scenario: Gateway performs provider work
- **WHEN** the Server admits a gateway session and the gateway requests provider work
- **THEN** the original provider-bearing `SessionAuthority` continues to bind provider work and subsequent release or cleanup
### Requirement: RC4 is a coordinated hard cut
The RC4 gateway and client SHALL use `ClientSessionAuthority`; mixed RC3/RC4 gateway-client compatibility SHALL NOT be claimed. RC4 SHALL NOT add optional provider fields, a dual decoder, or response negotiation for RC3.
#### Scenario: RC4 candidate is qualified
- **WHEN** the Protocol RC4 tag is pinned by Server, gateway, and client
- **THEN** qualification uses only that exact coordinated set
@@ -0,0 +1,10 @@
## 1. Contract and regressions
- [x] 1.1 Add RED-first Go, Swift, Rust, and Protobuf regressions for the strict provider-free authority.
- [x] 1.2 Add the exact seven-field JSON Schema and Protobuf `ClientSessionAuthority` without changing existing authority contracts.
- [x] 1.3 Regenerate Go, Rust, Swift, Protobuf, and manifest outputs using repository tooling.
## 2. Verification
- [x] 2.1 Pass focused Go and generated-contract regressions.
- [x] 2.2 Pass strict OpenSpec validation, full `make verify`, second-generation cleanliness, and diff checks.
+19 -7
View File
@@ -7,20 +7,32 @@ used across the authenticated Verse gateway boundary.
### Requirement: Typed sequenced input envelope
The `input.sequenced.v1` payload SHALL begin with ASCII `VGI1`, a one-byte
event kind, and one-byte payload length. It SHALL contain exactly one bounded
keyboard, mouse-button, relative-mouse, UTF-8 scalar, or controller-state
event. False keyboard/mouse state and zeroed controller state are explicit
releases. Multibyte integer fields SHALL be big-endian. Unknown kinds,
length mismatches, malformed UTF-8, unsupported controller indices, and
reserved fields SHALL be rejected before provider translation.
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 envelope with an unknown event kind, invalid
length, malformed UTF-8 scalar, or nonzero reserved field
- **WHEN** a client sends an unknown or unadvertised kind, invalid length,
malformed UTF-8 scalar, zero viewport, out-of-range coordinate, or nonzero
reserved field
- **THEN** the gateway rejects it without sending provider input or changing
pressed state.
@@ -0,0 +1,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.
+10
View File
@@ -117,6 +117,16 @@ message SessionAuthority {
string provider_identity = 9;
}
message ClientSessionAuthority {
string version = 1;
string session_id = 2;
string gateway_id = 3;
string audience = 4;
uint64 reconnect_sequence = 5;
google.protobuf.Timestamp expires_at = 6;
CapabilityProfile capabilities = 7;
}
message ProviderSessionWork {
string version = 1;
string session_id = 2;
+1
View File
@@ -2,6 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"features": [
{"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": "clipboard.text.v1", "direction": "bidirectional", "max_frame_bytes": 65536},
{"id": "display.request.v1", "direction": "client-to-server", "max_frame_bytes": 65536},
+65 -3
View File
@@ -178,6 +178,50 @@
"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,
@@ -191,13 +235,12 @@
"SessionRequest": {
"type": "object",
"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": {
"client_device_id": {"type": "string", "minLength": 1, "maxLength": 128},
"device_key_id": {"type": "string", "minLength": 1, "maxLength": 128},
"pool_id": {"type": "string", "minLength": 1, "maxLength": 128},
"idempotency_key": {"type": "string", "minLength": 1, "maxLength": 256},
"policy_snapshot": {"$ref": "#/$defs/AllocationPolicy"},
"requested_display_mode": {"$ref": "#/$defs/DisplayMode"}
}
},
@@ -274,7 +317,12 @@
"properties": {
"id": {"type": "string", "minLength": 1, "maxLength": 128},
"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": {
@@ -503,6 +551,20 @@
"provider_identity": {"type": "string", "minLength": 1, "maxLength": 256}
}
},
"ClientSessionAuthority": {
"type": "object",
"additionalProperties": false,
"required": ["version", "session_id", "gateway_id", "audience", "reconnect_sequence", "expires_at", "capabilities"],
"properties": {
"version": {"type": "string", "const": "1"},
"session_id": {"type": "string", "minLength": 1, "maxLength": 128},
"gateway_id": {"type": "string", "minLength": 1, "maxLength": 128},
"audience": {"type": "string", "minLength": 1, "maxLength": 256},
"reconnect_sequence": {"type": "integer", "minimum": 0},
"expires_at": {"type": "string", "format": "date-time", "maxLength": 64},
"capabilities": {"$ref": "#/$defs/CapabilityProfile"}
}
},
"ProviderStreamPolicy": {
"type": "object",
"additionalProperties": false,
+190 -6
View File
@@ -2,6 +2,8 @@ package protocol_test
import (
"bytes"
"encoding/hex"
"encoding/json"
"reflect"
"strings"
"testing"
@@ -9,8 +11,47 @@ import (
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) {
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))
if err != nil || manifest.Gateway.ID != "gateway-1" {
t.Fatalf("valid manifest = %+v, err = %v", manifest, err)
@@ -42,11 +83,6 @@ func TestDisplayModeIsOptionalStrictAndBounded(t *testing.T) {
request := protocol.SessionRequest{
ClientDeviceID: "device-1", DeviceKeyID: "key-1", PoolID: "pool-1", IdempotencyKey: "request-1",
PolicySnapshot: protocol.AllocationPolicy{
MinimumKbps: 1000, TargetKbps: 2000, MaximumKbps: 3000, Tier: "standard",
Audience: "versevdi-gateway", Protocol: "verse", ProtocolVersion: 1,
GrantTTLSeconds: 60, ReservationLeaseSeconds: 300,
},
}
legacy, err := protocol.EncodeSessionRequest(request)
if err != nil {
@@ -100,6 +136,79 @@ func TestDisplayModeIsOptionalStrictAndBounded(t *testing.T) {
}
}
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) {
if _, err := protocol.DecodePageInfo([]byte(`{"limit":101,"next_cursor":""}`)); err == nil {
t.Fatal("DecodePageInfo accepted limit above the contract maximum")
@@ -254,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) {
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 {
+98 -1
View File
@@ -150,6 +150,12 @@ def go_validation(definition: dict[str, Any]) -> list[str]:
if "maxItems" in prop:
lines.append(f"\tif len(v.{field}) > {prop['maxItems']} {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"max_items\"}}) }}")
items = prop.get("items", {})
if items.get("type") == "string" and "minLength" in items:
lines.append(f"\tfor _, item := range v.{field} {{ if len(item) < {items['minLength']} {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"min_item_length\"}}) }} }}")
if items.get("type") == "string" and "maxLength" in items:
lines.append(f"\tfor _, item := range v.{field} {{ if len(item) > {items['maxLength']} {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"max_item_length\"}}) }} }}")
if items.get("type") == "string" and "x-max-bytes" in items:
lines.append(f"\tfor _, item := range v.{field} {{ if len(item) > {items['x-max-bytes']} {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"max_item_bytes\"}}) }} }}")
if "enum" in items:
allowed = " || ".join(f'item == "{value}"' for value in items["enum"])
lines.append(f"\tfor _, item := range v.{field} {{ if !({allowed}) {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"invalid_item\"}}) }} }}")
@@ -181,6 +187,7 @@ def generate_go(defs: dict[str, dict[str, Any]], schema_hash: str, version: str,
"",
"import (",
"\"bytes\"",
"\"encoding/binary\"",
"\"encoding/base64\"",
"\"encoding/json\"",
"\"errors\"",
@@ -266,6 +273,22 @@ def generate_go(defs: dict[str, dict[str, Any]], schema_hash: str, version: str,
out.append("}")
out.append("")
out.extend([
"func DeviceRegistrationProofTranscript(serverID, principalID, deviceID, challenge []byte, expiryUnixMilliseconds int64) ([]byte, error) {",
"\tfor _, value := range []struct { field string; bytes []byte; length int }{{\"server_id\", serverID, 16}, {\"principal_id\", principalID, 16}, {\"device_id\", deviceID, 16}, {\"challenge\", challenge, 32}} {",
"\t\tif len(value.bytes) != value.length { return nil, ValidationError{Violations: []FieldViolation{{Field: value.field, Code: \"invalid_length\"}}} }",
"\t}",
"\tif expiryUnixMilliseconds < 0 { return nil, ValidationError{Violations: []FieldViolation{{Field: \"expiry_unix_milliseconds\", Code: \"minimum\"}}} }",
"\ttranscript := make([]byte, 0, 112)",
"\ttranscript = append(transcript, \"versevdi-device-proof-v1\"...)",
"\ttranscript = append(transcript, serverID...)",
"\ttranscript = append(transcript, principalID...)",
"\ttranscript = append(transcript, deviceID...)",
"\ttranscript = append(transcript, challenge...)",
"\tvar expiry [8]byte",
"\tbinary.BigEndian.PutUint64(expiry[:], uint64(expiryUnixMilliseconds))",
"\treturn append(transcript, expiry[:]...), nil",
"}",
"",
"var ErrNoCapabilityOverlap = errors.New(\"no capability overlap\")",
"",
"func IntersectCapabilityProfiles(profiles ...CapabilityProfile) (CapabilityProfile, error) {",
@@ -359,6 +382,8 @@ def rust_validation(definition: dict[str, Any]) -> list[str]:
if "enum" in prop:
allowed = " && ".join(f'{value} != \"{item}\"' for item in prop["enum"])
lines.append(f" {prefix}if {allowed} {{ return Err(ValidationError::new(\"{prop_name}\", \"invalid_value\")); }}")
if prop.get("format") == "date-time":
lines.append(f" {prefix}if !valid_rfc3339_utc({value}.as_str()) {{ return Err(ValidationError::new(\"{prop_name}\", \"invalid_time\")); }}")
if prop.get("format") == "base64url":
lines.append(f" {prefix}if !valid_base64_url({value}.as_str()) {{ return Err(ValidationError::new(\"{prop_name}\", \"invalid_format\")); }}")
if prop.get("type") == "integer":
@@ -372,6 +397,12 @@ def rust_validation(definition: dict[str, Any]) -> list[str]:
if "maxItems" in prop:
lines.append(f" {prefix}if {value}.len() > {prop['maxItems']} {{ return Err(ValidationError::new(\"{prop_name}\", \"max_items\")); }}")
items = prop.get("items", {})
if items.get("type") == "string" and "minLength" in items:
lines.append(f" {prefix}for item in {value}.iter() {{ if item.as_bytes().len() < {items['minLength']} {{ return Err(ValidationError::new(\"{prop_name}\", \"min_item_length\")); }} }}")
if items.get("type") == "string" and "maxLength" in items:
lines.append(f" {prefix}for item in {value}.iter() {{ if item.as_bytes().len() > {items['maxLength']} {{ return Err(ValidationError::new(\"{prop_name}\", \"max_item_length\")); }} }}")
if items.get("type") == "string" and "x-max-bytes" in items:
lines.append(f" {prefix}for item in {value}.iter() {{ if item.as_bytes().len() > {items['x-max-bytes']} {{ return Err(ValidationError::new(\"{prop_name}\", \"max_item_bytes\")); }} }}")
if "enum" in items:
allowed = " && ".join(f'item != \"{item}\"' for item in items["enum"])
lines.append(f" {prefix}for item in {value}.iter() {{ if {allowed} {{ return Err(ValidationError::new(\"{prop_name}\", \"invalid_item\")); }} }}")
@@ -429,6 +460,19 @@ def generate_rust(defs: dict[str, dict[str, Any]], schema_hash: str, compatibili
" _ => 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):
@@ -481,6 +525,21 @@ def generate_rust(defs: dict[str, dict[str, Any]], schema_hash: str, compatibili
])
out.extend(["}", ""])
out.extend([
"pub fn device_registration_proof_transcript(server_id: &[u8], principal_id: &[u8], device_id: &[u8], challenge: &[u8], expiry_unix_milliseconds: i64) -> Result<Vec<u8>, ValidationError> {",
" for (field, value, length) in [(\"server_id\", server_id, 16), (\"principal_id\", principal_id, 16), (\"device_id\", device_id, 16), (\"challenge\", challenge, 32)] {",
" if value.len() != length { return Err(ValidationError::new(field, \"invalid_length\")); }",
" }",
" if expiry_unix_milliseconds < 0 { return Err(ValidationError::new(\"expiry_unix_milliseconds\", \"minimum\")); }",
" let mut transcript = Vec::with_capacity(112);",
" transcript.extend_from_slice(b\"versevdi-device-proof-v1\");",
" transcript.extend_from_slice(server_id);",
" transcript.extend_from_slice(principal_id);",
" transcript.extend_from_slice(device_id);",
" transcript.extend_from_slice(challenge);",
" transcript.extend_from_slice(&(expiry_unix_milliseconds as u64).to_be_bytes());",
" Ok(transcript)",
"}",
"",
"pub fn intersect_capability_profiles(profiles: &[CapabilityProfile]) -> Result<CapabilityProfile, ValidationError> {",
" let mut selected = profiles.first().ok_or_else(|| ValidationError::new(\"capabilities\", \"no_overlap\"))?.clone();",
" selected.validate().map_err(|_| ValidationError::new(\"capabilities\", \"no_overlap\"))?;",
@@ -524,7 +583,7 @@ def swift_validation(definition: dict[str, Any]) -> list[str]:
allowed = ", ".join(f'\"{item}\"' for item in prop["enum"])
lines.append(f" {prefix}if ![{allowed}].contains({value}) {{ throw ContractValidationError(field: \"{prop_name}\", code: \"invalid_value\") }}")
if prop.get("format") == "date-time":
lines.append(f" {prefix}if 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":
lines.append(f" {prefix}if !validBase64URL({value}) {{ throw ContractValidationError(field: \"{prop_name}\", code: \"invalid_format\") }}")
if prop.get("type") == "integer":
@@ -538,6 +597,12 @@ def swift_validation(definition: dict[str, Any]) -> list[str]:
if "maxItems" in prop:
lines.append(f" {prefix}if {value}.count > {prop['maxItems']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"max_items\") }}")
items = prop.get("items", {})
if items.get("type") == "string" and "minLength" in items:
lines.append(f" {prefix}for item in {value} where item.utf8.count < {items['minLength']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"min_item_length\") }}")
if items.get("type") == "string" and "maxLength" in items:
lines.append(f" {prefix}for item in {value} where item.utf8.count > {items['maxLength']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"max_item_length\") }}")
if items.get("type") == "string" and "x-max-bytes" in items:
lines.append(f" {prefix}for item in {value} where item.utf8.count > {items['x-max-bytes']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"max_item_bytes\") }}")
if "enum" in items:
allowed = ", ".join(f'\"{item}\"' for item in items["enum"])
lines.append(f" {prefix}for item in {value} where ![{allowed}].contains(item) {{ throw ContractValidationError(field: \"{prop_name}\", code: \"invalid_item\") }}")
@@ -581,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 }",
" 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):
@@ -624,6 +706,21 @@ def generate_swift(defs: dict[str, dict[str, Any]], schema_hash: str, compatibil
out.extend(swift_validation(definition))
out.extend([" }", "", " public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }", " public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }", "}", ""])
out.extend([
"public func deviceRegistrationProofTranscript(serverID: Data, principalID: Data, deviceID: Data, challenge: Data, expiryUnixMilliseconds: Int64) throws -> Data {",
" for (field, value, length) in [(\"server_id\", serverID, 16), (\"principal_id\", principalID, 16), (\"device_id\", deviceID, 16), (\"challenge\", challenge, 32)] {",
" if value.count != length { throw ContractValidationError(field: field, code: \"invalid_length\") }",
" }",
" if expiryUnixMilliseconds < 0 { throw ContractValidationError(field: \"expiry_unix_milliseconds\", code: \"minimum\") }",
" var transcript = Data(\"versevdi-device-proof-v1\".utf8)",
" transcript.append(serverID)",
" transcript.append(principalID)",
" transcript.append(deviceID)",
" transcript.append(challenge)",
" var expiry = UInt64(expiryUnixMilliseconds).bigEndian",
" Swift.withUnsafeBytes(of: &expiry) { transcript.append(contentsOf: $0) }",
" return transcript",
"}",
"",
"public extension TunnelAdmissionRequest {",
" func deviceAdmissionTranscript() -> Data {",
" var fields = [sessionId, gatewayId, audience, grant, String(reconnectSequence), clientNonce, capabilities.transport, capabilities.framing, capabilities.media, capabilities.audio, capabilities.sourceRateControl, String(capabilities.clientDecode.count)]",
+74 -6
View File
@@ -38,7 +38,7 @@ func main() {
if len(fields) != 5 {
panic("invalid fixture row")
}
actual := evaluate(fields[2], fields[3])
actual := evaluate(fields[1], fields[2], fields[3])
if 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)
}
func evaluate(kind, input string) string {
func evaluate(version, kind, input string) string {
parts := map[string]string{}
for _, item := range strings.Split(input, ";") {
pair := strings.SplitN(item, "=", 2)
@@ -58,8 +58,22 @@ func evaluate(kind, input string) string {
}
}
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":
if input == "1" || input == "0" || input == "-1" {
if input == "2" || input == "1" || input == "0" {
return "valid"
}
return "invalid:unsupported_version"
@@ -81,7 +95,7 @@ func evaluate(kind, input string) string {
Version: parts["version"], Purpose: parts["purpose"], SessionID: "session-1",
ReconnectSequence: 0,
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"}},
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 "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":
sequence, sequenceErr := strconv.ParseInt(parts["sequence"], 10, 64)
payloadBytes, payloadErr := strconv.Atoi(parts["payload_bytes"])
@@ -126,8 +194,8 @@ func evaluate(kind, input string) string {
return "valid"
case "tunnel":
feature := parts["feature"]
registered := feature == "control.v1" || feature == "display.request.v1" || feature == "input.absolute.v1" || feature == "input.scroll.v1"
if (parts["offered"] == "1" || parts["offered"] == "0" || parts["offered"] == "-1") && registered {
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"
}
if !registered {
+74 -6
View File
@@ -9,10 +9,10 @@ fn values(input: &str) -> std::collections::BTreeMap<String, String> {
.collect()
}
fn evaluate(kind: &str, input: &str) -> &'static str {
fn evaluate(version: &str, kind: &str, input: &str) -> &'static str {
let values = values(input);
match kind {
"version" if matches!(input, "1" | "0" | "-1") => "valid",
"version" if matches!(input, "2" | "1" | "0") => "valid",
"version" => "invalid:unsupported_version",
"page" => match values.get("limit").and_then(|value| value.parse::<i64>().ok()) {
Some(limit) if (1..=100).contains(&limit) => "valid",
@@ -24,12 +24,62 @@ fn evaluate(kind: &str, input: &str) -> &'static str {
"manifest"
if values.get("version").map(String::as_str) == Some("1")
&& values.contains_key("gateway_id")
&& values.contains_key("public_identity")
&& values.get("grant").map_or(false, |value| value.len() >= 43)
&& values.get("purpose").map(String::as_str) == Some("launch") => "valid",
"manifest" => "invalid:invalid_manifest",
"clipboard" if values.get("encoding").map(String::as_str) == Some("utf-8")
&& !values.contains_key("file") => "valid",
"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") => {
"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)
|| !values.contains_key("correlation_id") => "invalid:required",
"event" => "valid",
"tunnel" if matches!(values.get("offered").map(String::as_str), Some("1") | Some("0") | Some("-1"))
&& matches!(values.get("feature").map(String::as_str), Some("control.v1") | Some("display.request.v1") | Some("input.absolute.v1") | Some("input.scroll.v1")) => "valid",
"tunnel" if !matches!(values.get("feature").map(String::as_str), Some("control.v1") | Some("display.request.v1") | Some("input.absolute.v1") | Some("input.scroll.v1")) => {
"tunnel" if matches!(values.get("offered").map(String::as_str), Some("2") | Some("1") | Some("0"))
&& 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 !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"
}
"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 {
let raw = match decode_hex(encoded) {
Some(raw) => raw,
@@ -283,7 +347,11 @@ fn main() {
for line in lines {
let fields: Vec<&str> = line.split('\t').collect();
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]);
results.push(format!("{}\t{}", fields[0], actual));
}
+53 -6
View File
@@ -9,17 +9,49 @@ func values(_ input: String) -> [String: String] {
return result
}
func evaluate(_ kind: String, _ input: String) -> String {
func evaluate(_ version: String, _ kind: String, _ input: String) -> String {
let values = values(input)
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":
guard let raw = values["limit"], let limit = Int(raw), (1...100).contains(limit) else { return "invalid:invalid_limit" }
return "valid"
case "manifest":
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 "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":
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" }
@@ -27,8 +59,8 @@ func evaluate(_ kind: String, _ input: String) -> String {
guard let sequence = Int(values["sequence"] ?? ""), sequence > 0, values["correlation_id"] != nil else { return "invalid:required" }
return "valid"
case "tunnel":
let registered = ["control.v1", "display.request.v1", "input.absolute.v1", "input.scroll.v1"].contains(values["feature"] ?? "")
if ["1", "0", "-1"].contains(values["offered"] ?? "") && registered { return "valid" }
let registered = ["control.v1", "control.v2", "display.request.v1", "input.absolute.v1", "input.scroll.v1"].contains(values["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 "gateway_input": return classifyGatewayInput(values["hex"] ?? "")
@@ -49,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]? {
let characters = Array(encoded)
guard characters.count % 2 == 0 else { return nil }
@@ -197,7 +242,9 @@ struct ConformanceMain {
for line in lines {
let fields = line.split(separator: "\t", omittingEmptySubsequences: false).map(String.init)
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])
results.append("\(fields[0])\t\(actual)")
}
+246 -20
View File
@@ -4,6 +4,7 @@
from __future__ import annotations
import pathlib
import re
import shutil
import subprocess
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))
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:
with tempfile.TemporaryDirectory(prefix="versevdi-generated-contracts-") as temporary:
workspace = pathlib.Path(temporary)
@@ -35,6 +62,9 @@ let capability = try CapabilityProfile(
transport: "quic-tls13", framing: "datagram-v1", media: "encoded",
audio: "encoded", sourceRateControl: "server", clientDecode: ["h264-opus"]
)
guard currentWireVersion == "2", nMinus1WireVersion == "1", nMinus2WireVersion == "0" else {
fatalError("unexpected control wire compatibility declaration")
}
_ = try CapabilityProfile(
transport: "quic-tls13", framing: "datagram-v2", media: "encoded",
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 {
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(
transport: "quic-tls13", framing: "datagram-v1", media: "encoded",
audio: "encoded", sourceRateControl: "server", clientDecode: ["hevc-opus"]
@@ -95,6 +163,52 @@ for invalid in [
fatalError("invalid tunnel admission request was accepted")
} 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 {
_ = try AllocationPolicy(
minimumKbps: 100, targetKbps: 50, maximumKbps: 25, tier: "standard",
@@ -114,23 +228,16 @@ for invalid in [
fatalError("invalid display mode was accepted")
} catch { }
}
let allocationPolicy = try AllocationPolicy(
minimumKbps: 1000, targetKbps: 2000, maximumKbps: 3000, tier: "standard",
audience: "versevdi-gateway", protocolValue: "verse", protocolVersion: 1,
grantTtlSeconds: 60, reservationLeaseSeconds: 300
)
let legacyDisplayRequest = try SessionRequest(
let policyFreeV2Request = try SessionRequest(
clientDeviceId: "device-1", deviceKeyId: "key-1", poolId: "pool-1",
idempotencyKey: "request-1", policySnapshot: allocationPolicy,
requestedDisplayMode: nil
idempotencyKey: "request-1", requestedDisplayMode: nil
).encodeJSON()
guard !String(data: legacyDisplayRequest, encoding: .utf8)!.contains("requested_display_mode") else {
fatalError("legacy request encoded an absent display mode")
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", policySnapshot: allocationPolicy,
requestedDisplayMode: displayMode
idempotencyKey: "request-1", requestedDisplayMode: displayMode
)
guard try SessionRequest.decodeJSON(displayRequest.encodeJSON()).requestedDisplayMode == displayMode else {
fatalError("display mode did not round-trip")
@@ -141,6 +248,52 @@ 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(
resolutionWidth: 2560, resolutionHeight: 1440, fps: 120,
codec: "HEVC", bitrateKbps: 40000, audioEnabled: true
@@ -207,6 +360,9 @@ do {
output.write(
"""
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(
"quic-tls13".into(), "datagram-v1".into(), "encoded".into(),
"encoded".into(), "server".into(), vec!["h264-opus".into()],
@@ -227,6 +383,32 @@ fn main() {
+ &"g".repeat(43) + "1:016:" + &"n".repeat(16)
+ "10:quic-tls1311:datagram-v17:encoded7:encoded6:server1:19:h264-opus";
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(
"2".into(), "session".into(), "gateway".into(), "audience".into(),
"g".repeat(43), 0, "n".repeat(16), "s".repeat(86), capabilities.clone(),
@@ -239,6 +421,16 @@ fn main() {
"1".into(), "session".into(), "gateway".into(), "audience".into(),
"g".repeat(43), 0, "short".into(), "s".repeat(86), capabilities.clone(),
).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());
let incompatible = CapabilityProfile::new(
"quic-tls13".into(), "datagram-v1".into(), "encoded".into(),
@@ -260,20 +452,39 @@ fn main() {
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 allocation_policy = AllocationPolicy::new(
1000, 2000, 3000, "standard".into(), "versevdi-gateway".into(),
"verse".into(), 1, 60, 300,
).unwrap();
let legacy_display_request = SessionRequest::new(
let policy_free_v2_request = SessionRequest::new(
"device-1".into(), "key-1".into(), "pool-1".into(), "request-1".into(),
allocation_policy.clone(), None,
None,
).unwrap();
assert!(legacy_display_request.requestedDisplayMode().is_none());
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(),
allocation_policy, Some(display_mode.clone()),
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(
2560, 1440, 120, "HEVC".into(), 40000, true,
).is_ok());
@@ -313,6 +524,21 @@ fn main() {
)
run(["rustc", str(rust), "-o", 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"
shutil.copyfile(ROOT / "gen/rust/protocol.rs", rust_unknown)
with rust_unknown.open("a", encoding="utf-8") as output:
+98 -3
View File
@@ -24,7 +24,7 @@ def main() -> int:
assert set(definition["required"]).issubset(definition["properties"]), name
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"])
for registry in ("registries/features.json", "registries/datagrams.json"):
@@ -38,7 +38,7 @@ def main() -> int:
feature_registry = json.loads((ROOT / "registries/features.json").read_text(encoding="utf-8"))
registered_features = {entry["id"] for entry in feature_registry["features"]}
assert {"display.request.v1", "input.absolute.v1", "input.scroll.v1"}.issubset(registered_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"]
@@ -54,10 +54,57 @@ def main() -> int:
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"
]
manifest = json.loads((ROOT / "fixtures/valid/manifest.json").read_text(encoding="utf-8"))
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"))
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"
ids = set()
@@ -69,7 +116,9 @@ def main() -> int:
assert len(fields) == 5, line
assert fields[0] not in ids, 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"))
assert fixture_manifest["files"] == sorted(
@@ -87,6 +136,52 @@ def main() -> int:
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 "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")
return 0