Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6a4f773e4 | ||
|
|
afcd5d99db | ||
|
|
79d9e49497 |
+1
-1
@@ -21,10 +21,10 @@
|
||||
# Go workspace file
|
||||
go.work
|
||||
go.work.sum
|
||||
/.build
|
||||
|
||||
# env file
|
||||
.env
|
||||
|
||||
# IDE files
|
||||
/.idea
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// swift-tools-version: 6.0
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "VerseVDIProtocol",
|
||||
platforms: [.macOS(.v14)],
|
||||
products: [
|
||||
.library(name: "VerseVDIProtocol", targets: ["VerseVDIProtocol"]),
|
||||
],
|
||||
targets: [
|
||||
.target(name: "VerseVDIProtocol", path: "gen/swift", sources: ["Protocol.swift"]),
|
||||
]
|
||||
)
|
||||
+4
-4
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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,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
|
||||
|
||||
|
@@ -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": {}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"client_device_id": "device-1",
|
||||
"device_key_id": "key-1",
|
||||
"pool_id": "pool-1",
|
||||
"idempotency_key": "request-1"
|
||||
}
|
||||
+499
-19
@@ -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 = "dea3dd210c53d5a2d37050dd6afd8b0ac5bb8edcb7ab25a02e4026489ce8a00f"
|
||||
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"`
|
||||
@@ -281,6 +289,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 +306,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 +421,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 +891,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 == "" {
|
||||
@@ -2689,6 +2816,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 +3368,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 +3518,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 +3590,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 +3801,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 +4320,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 +5216,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 +5245,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 +5503,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 +5565,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
@@ -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": "dea3dd210c53d5a2d37050dd6afd8b0ac5bb8edcb7ab25a02e4026489ce8a00f"
|
||||
}
|
||||
|
||||
+194
-9
@@ -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 = "dea3dd210c53d5a2d37050dd6afd8b0ac5bb8edcb7ab25a02e4026489ce8a00f";
|
||||
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,
|
||||
@@ -428,6 +478,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 +687,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 +841,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 +876,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 +947,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 +1072,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 +1160,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 +1215,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 +1293,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 +1308,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 +1434,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 +1515,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 +1583,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 +1800,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 +1825,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 +1847,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 +1856,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 +1967,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"))?;
|
||||
|
||||
+242
-23
@@ -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 = "dea3dd210c53d5a2d37050dd6afd8b0ac5bb8edcb7ab25a02e4026489ce8a00f"
|
||||
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
|
||||
@@ -551,7 +613,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 +893,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 +1098,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 +1151,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 +1247,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 +1397,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 +1515,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 +1588,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 +1695,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 +1706,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 +1907,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 +1985,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 +2074,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 +2375,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 +2392,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 +2414,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 +2430,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 +2574,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
@@ -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
|
||||
|
||||
+3
-3
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
+114
-6
@@ -2,6 +2,7 @@ package protocol_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -9,8 +10,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 +82,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 +135,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")
|
||||
|
||||
+98
-1
@@ -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)]",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
|
||||
@@ -35,6 +35,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 +59,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"]
|
||||
@@ -114,23 +155,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 +175,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 +287,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 +310,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(),
|
||||
@@ -260,20 +369,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());
|
||||
|
||||
+98
-3
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user