diff --git a/.gitignore b/.gitignore index 03b476b..88478b7 100644 --- a/.gitignore +++ b/.gitignore @@ -21,10 +21,10 @@ # Go workspace file go.work go.work.sum +/.build # env file .env # IDE files /.idea - diff --git a/Makefile b/Makefile index adf5c45..bdbe674 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..b1dddbf --- /dev/null +++ b/Package.swift @@ -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"]), + ] +) diff --git a/compatibility.json b/compatibility.json index 6c17fa9..8309c4f 100644 --- a/compatibility.json +++ b/compatibility.json @@ -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" } diff --git a/fixtures/conformance/control-v1.tsv b/fixtures/conformance/control-v1.tsv index ffc5d2a..fb562ec 100644 --- a/fixtures/conformance/control-v1.tsv +++ b/fixtures/conformance/control-v1.tsv @@ -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 diff --git a/fixtures/conformance/tunnel-v1.tsv b/fixtures/conformance/tunnel-v1.tsv index dfbbc31..0b1bf56 100644 --- a/fixtures/conformance/tunnel-v1.tsv +++ b/fixtures/conformance/tunnel-v1.tsv @@ -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 diff --git a/fixtures/invalid/authenticated-session-partial-native-identity.json b/fixtures/invalid/authenticated-session-partial-native-identity.json new file mode 100644 index 0000000..665ca2d --- /dev/null +++ b/fixtures/invalid/authenticated-session-partial-native-identity.json @@ -0,0 +1,9 @@ +{ + "username": "alice", + "provider": "local", + "roles": ["user"], + "role": "user", + "native_identity": { + "client_device_id": "device-1" + } +} diff --git a/fixtures/invalid/browser-session-native-identity.json b/fixtures/invalid/browser-session-native-identity.json new file mode 100644 index 0000000..0d4d947 --- /dev/null +++ b/fixtures/invalid/browser-session-native-identity.json @@ -0,0 +1,10 @@ +{ + "username": "alice", + "provider": "local", + "roles": ["user"], + "role": "user", + "native_identity": { + "client_device_id": "device-1", + "device_key_id": "key-1" + } +} diff --git a/fixtures/invalid/manifest-provider-field.json b/fixtures/invalid/manifest-provider-field.json index 59ddff3..4efc393 100644 --- a/fixtures/invalid/manifest-provider-field.json +++ b/fixtures/invalid/manifest-provider-field.json @@ -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"], diff --git a/fixtures/invalid/native-session-missing-identity.json b/fixtures/invalid/native-session-missing-identity.json new file mode 100644 index 0000000..feedf56 --- /dev/null +++ b/fixtures/invalid/native-session-missing-identity.json @@ -0,0 +1,6 @@ +{ + "username": "alice", + "provider": "local", + "roles": ["user"], + "role": "user" +} diff --git a/fixtures/invalid/session-request-policy-snapshot.json b/fixtures/invalid/session-request-policy-snapshot.json new file mode 100644 index 0000000..9e86a67 --- /dev/null +++ b/fixtures/invalid/session-request-policy-snapshot.json @@ -0,0 +1,7 @@ +{ + "client_device_id": "device-1", + "device_key_id": "key-1", + "pool_id": "pool-1", + "idempotency_key": "request-1", + "policy_snapshot": {} +} diff --git a/fixtures/manifest.json b/fixtures/manifest.json index 74aa16f..2cc6dc9 100644 --- a/fixtures/manifest.json +++ b/fixtures/manifest.json @@ -10,5 +10,5 @@ "fixtures/conformance/gateway-input-feedback-v1.tsv", "fixtures/conformance/tunnel-v1.tsv" ], - "corpus_sha256": "4f8bf520d4f61588235b00776b21202ff701b20d6d11b461037b911c2117852b" + "corpus_sha256": "ed69937656f395b30f520861948f82ed3c0b21ea86e9b33c7949ed09942e701d" } diff --git a/fixtures/valid/authenticated-browser-session.json b/fixtures/valid/authenticated-browser-session.json new file mode 100644 index 0000000..feedf56 --- /dev/null +++ b/fixtures/valid/authenticated-browser-session.json @@ -0,0 +1,6 @@ +{ + "username": "alice", + "provider": "local", + "roles": ["user"], + "role": "user" +} diff --git a/fixtures/valid/authenticated-native-session.json b/fixtures/valid/authenticated-native-session.json new file mode 100644 index 0000000..0d4d947 --- /dev/null +++ b/fixtures/valid/authenticated-native-session.json @@ -0,0 +1,10 @@ +{ + "username": "alice", + "provider": "local", + "roles": ["user"], + "role": "user", + "native_identity": { + "client_device_id": "device-1", + "device_key_id": "key-1" + } +} diff --git a/fixtures/valid/manifest.json b/fixtures/valid/manifest.json index 245812d..dfdebde 100644 --- a/fixtures/valid/manifest.json +++ b/fixtures/valid/manifest.json @@ -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"], diff --git a/fixtures/valid/native-tunnel-credential.json b/fixtures/valid/native-tunnel-credential.json new file mode 100644 index 0000000..5bffc12 --- /dev/null +++ b/fixtures/valid/native-tunnel-credential.json @@ -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" +} diff --git a/fixtures/valid/session-request.json b/fixtures/valid/session-request.json new file mode 100644 index 0000000..d878df6 --- /dev/null +++ b/fixtures/valid/session-request.json @@ -0,0 +1,6 @@ +{ + "client_device_id": "device-1", + "device_key_id": "key-1", + "pool_id": "pool-1", + "idempotency_key": "request-1" +} diff --git a/gen/go/protocol/protocol.go b/gen/go/protocol/protocol.go index 5c0a34c..0aa384f 100644 --- a/gen/go/protocol/protocol.go +++ b/gen/go/protocol/protocol.go @@ -13,11 +13,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 +69,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 +288,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 +305,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 +420,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 +890,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 +2815,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 +3367,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 +3517,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 +3589,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 +3800,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 +4319,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 +5215,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 +5244,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 +5502,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} } diff --git a/gen/manifest.json b/gen/manifest.json index b7ead5c..6d04f60 100644 --- a/gen/manifest.json +++ b/gen/manifest.json @@ -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": "8a153cf1e99682d010ff91c754ef056c64aece8f8bbca0ca58f8eef2b9039119", "protocol_version": "1.0.0", - "schema_sha256": "b2bb0a8ac8ef56dbc0e1443eeb5b3028be9e71ec2f5fd8e73928d71b7cd9340c" + "schema_sha256": "dea3dd210c53d5a2d37050dd6afd8b0ac5bb8edcb7ab25a02e4026489ce8a00f" } diff --git a/gen/rust/protocol.rs b/gen/rust/protocol.rs index 1b623c3..d222c44 100644 --- a/gen/rust/protocol.rs +++ b/gen/rust/protocol.rs @@ -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; #[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 { 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 { &self.effectiveDisplayMode } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BrowserAuthenticatedSession { + username: String, + provider: String, + roles: Vec, + role: String, +} + +impl BrowserAuthenticatedSession { + pub fn new(username: String, provider: String, roles: Vec, role: String) -> Result { + 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 { &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 { &self.versions } pub fn features(&self) -> &Vec { &self.features } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NativeAuthenticatedSession { + username: String, + provider: String, + roles: Vec, + role: String, + nativeIdentity: NativeSessionIdentity, +} + +impl NativeAuthenticatedSession { + pub fn new(username: String, provider: String, roles: Vec, role: String, nativeIdentity: NativeSessionIdentity) -> Result { + 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 { &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, @@ -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 { &self.refreshExpiresAt } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NativeSessionIdentity { + clientDeviceId: String, + deviceKeyId: String, +} + +impl NativeSessionIdentity { + pub fn new(clientDeviceId: String, deviceKeyId: String) -> Result { + 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 { + 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, } impl SessionRequest { - pub fn new(clientDeviceId: String, deviceKeyId: String, poolId: String, idempotencyKey: String, policySnapshot: AllocationPolicy, requestedDisplayMode: Option) -> Result { - let value = Self { clientDeviceId, deviceKeyId, poolId, idempotencyKey, policySnapshot, requestedDisplayMode }; + pub fn new(clientDeviceId: String, deviceKeyId: String, poolId: String, idempotencyKey: String, requestedDisplayMode: Option) -> Result { + 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 { &self.requestedDisplayMode } } @@ -1799,7 +1967,9 @@ 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 { &self.supportedVersions } diff --git a/gen/swift/Protocol.swift b/gen/swift/Protocol.swift index 700171d..b06b4b1 100644 --- a/gen/swift/Protocol.swift +++ b/gen/swift/Protocol.swift @@ -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? { + 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,7 +2574,9 @@ 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) } diff --git a/openapi/control-v1.yaml b/openapi/control-v1.yaml index cd0c266..452e340 100644 --- a/openapi/control-v1.yaml +++ b/openapi/control-v1.yaml @@ -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: @@ -189,6 +196,7 @@ 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: [] - nativeBearer: [] diff --git a/openspec/specs/native-session-tunnel-credentials/spec.md b/openspec/specs/native-session-tunnel-credentials/spec.md new file mode 100644 index 0000000..9c7503c --- /dev/null +++ b/openspec/specs/native-session-tunnel-credentials/spec.md @@ -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` diff --git a/registries/features.json b/registries/features.json index e4712b7..3024bde 100644 --- a/registries/features.json +++ b/registries/features.json @@ -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}, diff --git a/schemas/control-v1.schema.json b/schemas/control-v1.schema.json index c899729..d229e9a 100644 --- a/schemas/control-v1.schema.json +++ b/schemas/control-v1.schema.json @@ -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": { diff --git a/tests/go/protocol_test.go b/tests/go/protocol_test.go index effb479..950e2fe 100644 --- a/tests/go/protocol_test.go +++ b/tests/go/protocol_test.go @@ -10,7 +10,7 @@ import ( ) 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 +42,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 +95,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") diff --git a/tools/generate.py b/tools/generate.py index 4e80f5b..5972a10 100644 --- a/tools/generate.py +++ b/tools/generate.py @@ -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\"}}) }} }}") @@ -359,6 +365,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 +380,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 +443,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 { 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): @@ -524,7 +551,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 +565,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 +614,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? {", + " 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): diff --git a/tools/go-conformance/main.go b/tools/go-conformance/main.go index 9acac0b..435cae8 100644 --- a/tools/go-conformance/main.go +++ b/tools/go-conformance/main.go @@ -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) @@ -59,7 +59,7 @@ func evaluate(kind, input string) string { } switch kind { case "version": - if input == "1" || input == "0" || input == "-1" { + if input == "2" || input == "1" || input == "0" { return "valid" } return "invalid:unsupported_version" @@ -81,7 +81,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 +99,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 +180,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 { diff --git a/tools/native_conformance.rs b/tools/native_conformance.rs index d9391c2..976242d 100644 --- a/tools/native_conformance.rs +++ b/tools/native_conformance.rs @@ -9,10 +9,10 @@ fn values(input: &str) -> std::collections::BTreeMap { .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::().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::().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", @@ -283,7 +333,7 @@ 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 = evaluate(fields[1], fields[2], fields[3]); assert_eq!(actual, fields[4], "{}", fields[0]); results.push(format!("{}\t{}", fields[0], actual)); } diff --git a/tools/native_conformance.swift b/tools/native_conformance.swift index 72e80d0..c266278 100644 --- a/tools/native_conformance.swift +++ b/tools/native_conformance.swift @@ -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"] ?? "") @@ -197,7 +229,7 @@ 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 = evaluate(fields[1], fields[2], fields[3]) precondition(actual == fields[4], fields[0]) results.append("\(fields[0])\t\(actual)") } diff --git a/tools/test_generated_contracts.py b/tools/test_generated_contracts.py index 0b9aee9..b5c0b9b 100644 --- a/tools/test_generated_contracts.py +++ b/tools/test_generated_contracts.py @@ -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"] @@ -114,23 +117,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 +137,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 +249,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()], @@ -260,20 +305,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()); diff --git a/tools/validate.py b/tools/validate.py index 3feeb97..1f241fd 100644 --- a/tools/validate.py +++ b/tools/validate.py @@ -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() @@ -87,6 +134,20 @@ 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 + 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