Compare commits

...
Author SHA1 Message Date
sechmachine afbcea62f9 Protocol: split client session authority
Verify Protocol / verify (push) Successful in 1m2s
Verify Protocol / module (push) Successful in 1m45s
2026-08-12 11:50:41 +07:00
14 changed files with 536 additions and 4 deletions
+127 -1
View File
@@ -14,7 +14,7 @@ import (
"time"
)
const SchemaSHA256 = "dea3dd210c53d5a2d37050dd6afd8b0ac5bb8edcb7ab25a02e4026489ce8a00f"
const SchemaSHA256 = "762d009c3d25d80c3850d975e45f7a6b3fd8adf5c93c8fa7dd11dfa993f8bbb1"
const ProtocolVersion = "1.0.0"
const CurrentWireVersion = "2"
const NMinus1WireVersion = "1"
@@ -97,6 +97,16 @@ type ChannelFrame struct {
Payload string `json:"payload"`
}
type ClientSessionAuthority struct {
Version string `json:"version"`
SessionID string `json:"session_id"`
GatewayID string `json:"gateway_id"`
Audience string `json:"audience"`
ReconnectSequence int64 `json:"reconnect_sequence"`
ExpiresAt string `json:"expires_at"`
Capabilities CapabilityProfile `json:"capabilities"`
}
type ClipboardPolicy struct {
ClientToProviderEnabled bool `json:"client_to_provider_enabled"`
ProviderToClientEnabled bool `json:"provider_to_client_enabled"`
@@ -1233,6 +1243,122 @@ func EncodeChannelFrame(value ChannelFrame) ([]byte, error) {
return json.Marshal(value)
}
func (v ClientSessionAuthority) Validate() error {
var violations []FieldViolation
if v.Version == "" {
violations = append(violations, FieldViolation{Field: "version", Code: "required"})
}
if v.Version != "1" && v.Version != "" {
violations = append(violations, FieldViolation{Field: "version", Code: "invalid_value"})
}
if v.SessionID == "" {
violations = append(violations, FieldViolation{Field: "session_id", Code: "required"})
}
if len(v.SessionID) < 1 && v.SessionID != "" {
violations = append(violations, FieldViolation{Field: "session_id", Code: "min_length"})
}
if len(v.SessionID) > 128 {
violations = append(violations, FieldViolation{Field: "session_id", Code: "max_length"})
}
if v.GatewayID == "" {
violations = append(violations, FieldViolation{Field: "gateway_id", Code: "required"})
}
if len(v.GatewayID) < 1 && v.GatewayID != "" {
violations = append(violations, FieldViolation{Field: "gateway_id", Code: "min_length"})
}
if len(v.GatewayID) > 128 {
violations = append(violations, FieldViolation{Field: "gateway_id", Code: "max_length"})
}
if v.Audience == "" {
violations = append(violations, FieldViolation{Field: "audience", Code: "required"})
}
if len(v.Audience) < 1 && v.Audience != "" {
violations = append(violations, FieldViolation{Field: "audience", Code: "min_length"})
}
if len(v.Audience) > 256 {
violations = append(violations, FieldViolation{Field: "audience", Code: "max_length"})
}
if v.ReconnectSequence != 0 && v.ReconnectSequence < 0 {
violations = append(violations, FieldViolation{Field: "reconnect_sequence", Code: "minimum"})
}
if v.ExpiresAt == "" {
violations = append(violations, FieldViolation{Field: "expires_at", Code: "required"})
}
if len(v.ExpiresAt) > 64 {
violations = append(violations, FieldViolation{Field: "expires_at", Code: "max_length"})
}
if v.ExpiresAt != "" {
if parsed, err := time.Parse(time.RFC3339Nano, v.ExpiresAt); err != nil || parsed.UTC().Format(time.RFC3339Nano) != v.ExpiresAt {
violations = append(violations, FieldViolation{Field: "expires_at", Code: "invalid_time"})
}
}
if reflect.DeepEqual(v.Capabilities, CapabilityProfile{}) {
violations = append(violations, FieldViolation{Field: "capabilities", Code: "required"})
}
if err := v.Capabilities.Validate(); err != nil {
violations = append(violations, FieldViolation{Field: "capabilities", Code: "invalid_object"})
}
if len(violations) > 0 {
return ValidationError{Violations: violations}
}
return nil
}
func DecodeClientSessionAuthority(data []byte) (ClientSessionAuthority, error) {
var value ClientSessionAuthority
if len(data) > 1024*1024 {
return value, errors.New("protocol payload exceeds limit")
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(data, &fields); err != nil {
return value, err
}
if raw, ok := fields["audience"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return value, ValidationError{Violations: []FieldViolation{{Field: "audience", Code: "required"}}}
}
if raw, ok := fields["capabilities"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return value, ValidationError{Violations: []FieldViolation{{Field: "capabilities", Code: "required"}}}
}
if raw, ok := fields["expires_at"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return value, ValidationError{Violations: []FieldViolation{{Field: "expires_at", Code: "required"}}}
}
if raw, ok := fields["gateway_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return value, ValidationError{Violations: []FieldViolation{{Field: "gateway_id", Code: "required"}}}
}
if raw, ok := fields["reconnect_sequence"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return value, ValidationError{Violations: []FieldViolation{{Field: "reconnect_sequence", Code: "required"}}}
}
if raw, ok := fields["session_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return value, ValidationError{Violations: []FieldViolation{{Field: "session_id", Code: "required"}}}
}
if raw, ok := fields["version"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return value, ValidationError{Violations: []FieldViolation{{Field: "version", Code: "required"}}}
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&value); err != nil {
return value, err
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
if err == nil {
return value, errors.New("trailing JSON value")
}
return value, err
}
if err := value.Validate(); err != nil {
return value, err
}
return value, nil
}
func EncodeClientSessionAuthority(value ClientSessionAuthority) ([]byte, error) {
if err := value.Validate(); err != nil {
return nil, err
}
return json.Marshal(value)
}
func (v ClipboardPolicy) Validate() error {
var violations []FieldViolation
if v.MaxTextBytes == 0 {
+1 -1
View File
@@ -14,5 +14,5 @@
},
"generator_sha256": "00c1905fc611ca9e226cd90da761b48b8e203734b10542befea397a30082d360",
"protocol_version": "1.0.0",
"schema_sha256": "dea3dd210c53d5a2d37050dd6afd8b0ac5bb8edcb7ab25a02e4026489ce8a00f"
"schema_sha256": "762d009c3d25d80c3850d975e45f7a6b3fd8adf5c93c8fa7dd11dfa993f8bbb1"
}
Binary file not shown.
+44 -1
View File
@@ -1,6 +1,6 @@
// Code generated by tools/generate.py; DO NOT EDIT.
#![allow(non_snake_case)]
pub const SCHEMA_SHA256: &str = "dea3dd210c53d5a2d37050dd6afd8b0ac5bb8edcb7ab25a02e4026489ce8a00f";
pub const SCHEMA_SHA256: &str = "762d009c3d25d80c3850d975e45f7a6b3fd8adf5c93c8fa7dd11dfa993f8bbb1";
pub const CURRENT_WIRE_VERSION: &str = "2";
pub const N_MINUS_1_WIRE_VERSION: &str = "1";
pub const N_MINUS_2_WIRE_VERSION: &str = "0";
@@ -353,6 +353,49 @@ impl ChannelFrame {
pub fn payload(&self) -> &String { &self.payload }
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientSessionAuthority {
version: String,
sessionId: String,
gatewayId: String,
audience: String,
reconnectSequence: i64,
expiresAt: String,
capabilities: CapabilityProfile,
}
impl ClientSessionAuthority {
pub fn new(version: String, sessionId: String, gatewayId: String, audience: String, reconnectSequence: i64, expiresAt: String, capabilities: CapabilityProfile) -> Result<Self, ValidationError> {
let value = Self { version, sessionId, gatewayId, audience, reconnectSequence, expiresAt, capabilities };
value.validate()?;
Ok(value)
}
pub fn validate(&self) -> Result<(), ValidationError> {
if self.version != "1" { return Err(ValidationError::new("version", "invalid_value")); }
if self.sessionId.is_empty() { return Err(ValidationError::new("session_id", "required")); }
if !self.sessionId.is_empty() && self.sessionId.len() < 1 { return Err(ValidationError::new("session_id", "min_length")); }
if self.sessionId.len() > 128 { return Err(ValidationError::new("session_id", "max_length")); }
if self.gatewayId.is_empty() { return Err(ValidationError::new("gateway_id", "required")); }
if !self.gatewayId.is_empty() && self.gatewayId.len() < 1 { return Err(ValidationError::new("gateway_id", "min_length")); }
if self.gatewayId.len() > 128 { return Err(ValidationError::new("gateway_id", "max_length")); }
if self.audience.is_empty() { return Err(ValidationError::new("audience", "required")); }
if !self.audience.is_empty() && self.audience.len() < 1 { return Err(ValidationError::new("audience", "min_length")); }
if self.audience.len() > 256 { return Err(ValidationError::new("audience", "max_length")); }
if self.reconnectSequence < 0 { return Err(ValidationError::new("reconnect_sequence", "minimum")); }
if self.expiresAt.len() > 64 { return Err(ValidationError::new("expires_at", "max_length")); }
if !valid_rfc3339_utc(self.expiresAt.as_str()) { return Err(ValidationError::new("expires_at", "invalid_time")); }
self.capabilities.validate().map_err(|_| ValidationError::new("capabilities", "invalid_object"))?;
Ok(())
}
pub fn version(&self) -> &String { &self.version }
pub fn sessionId(&self) -> &String { &self.sessionId }
pub fn gatewayId(&self) -> &String { &self.gatewayId }
pub fn audience(&self) -> &String { &self.audience }
pub fn reconnectSequence(&self) -> &i64 { &self.reconnectSequence }
pub fn expiresAt(&self) -> &String { &self.expiresAt }
pub fn capabilities(&self) -> &CapabilityProfile { &self.capabilities }
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClipboardPolicy {
clientToProviderEnabled: bool,
+58 -1
View File
@@ -1,7 +1,7 @@
// Code generated by tools/generate.py; DO NOT EDIT.
import Foundation
public typealias JSONObject = [String: String]
public let schemaSHA256 = "dea3dd210c53d5a2d37050dd6afd8b0ac5bb8edcb7ab25a02e4026489ce8a00f"
public let schemaSHA256 = "762d009c3d25d80c3850d975e45f7a6b3fd8adf5c93c8fa7dd11dfa993f8bbb1"
public let currentWireVersion = "2"
public let nMinus1WireVersion = "1"
public let nMinus2WireVersion = "0"
@@ -433,6 +433,63 @@ public struct ChannelFrame: Codable, Equatable {
public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }
}
public struct ClientSessionAuthority: Codable, Equatable {
public let version: String
public let sessionId: String
public let gatewayId: String
public let audience: String
public let reconnectSequence: Int64
public let expiresAt: String
public let capabilities: CapabilityProfile
enum CodingKeys: String, CodingKey {
case version = "version"
case sessionId = "session_id"
case gatewayId = "gateway_id"
case audience = "audience"
case reconnectSequence = "reconnect_sequence"
case expiresAt = "expires_at"
case capabilities = "capabilities"
}
public init(version: String, sessionId: String, gatewayId: String, audience: String, reconnectSequence: Int64, expiresAt: String, capabilities: CapabilityProfile) throws {
self.version = version
self.sessionId = sessionId
self.gatewayId = gatewayId
self.audience = audience
self.reconnectSequence = reconnectSequence
self.expiresAt = expiresAt
self.capabilities = capabilities
try validate()
}
public init(from decoder: Decoder) throws {
let all = try decoder.container(keyedBy: AnyCodingKey.self)
for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") }
let c = try decoder.container(keyedBy: CodingKeys.self)
try self.init(version: try c.decode(String.self, forKey: .version), sessionId: try c.decode(String.self, forKey: .sessionId), gatewayId: try c.decode(String.self, forKey: .gatewayId), audience: try c.decode(String.self, forKey: .audience), reconnectSequence: try c.decode(Int64.self, forKey: .reconnectSequence), expiresAt: try c.decode(String.self, forKey: .expiresAt), capabilities: try c.decode(CapabilityProfile.self, forKey: .capabilities))
}
public func validate() throws {
if self.version != "1" { throw ContractValidationError(field: "version", code: "invalid_value") }
if self.sessionId.isEmpty { throw ContractValidationError(field: "session_id", code: "required") }
if !self.sessionId.isEmpty && self.sessionId.utf8.count < 1 { throw ContractValidationError(field: "session_id", code: "min_length") }
if self.sessionId.utf8.count > 128 { throw ContractValidationError(field: "session_id", code: "max_length") }
if self.gatewayId.isEmpty { throw ContractValidationError(field: "gateway_id", code: "required") }
if !self.gatewayId.isEmpty && self.gatewayId.utf8.count < 1 { throw ContractValidationError(field: "gateway_id", code: "min_length") }
if self.gatewayId.utf8.count > 128 { throw ContractValidationError(field: "gateway_id", code: "max_length") }
if self.audience.isEmpty { throw ContractValidationError(field: "audience", code: "required") }
if !self.audience.isEmpty && self.audience.utf8.count < 1 { throw ContractValidationError(field: "audience", code: "min_length") }
if self.audience.utf8.count > 256 { throw ContractValidationError(field: "audience", code: "max_length") }
if self.reconnectSequence < 0 { throw ContractValidationError(field: "reconnect_sequence", code: "minimum") }
if self.expiresAt.utf8.count > 64 { throw ContractValidationError(field: "expires_at", code: "max_length") }
if !validRFC3339UTC(self.expiresAt) { throw ContractValidationError(field: "expires_at", code: "invalid_time") }
try self.capabilities.validate()
}
public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }
public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }
}
public struct ClipboardPolicy: Codable, Equatable {
public let clientToProviderEnabled: Bool
public let providerToClientEnabled: Bool
@@ -0,0 +1,4 @@
schema: spec-driven
created: 2026-08-12
goal: Split client-facing session authority from the provider-bearing
Server-to-gateway authority for the coordinated RC4 hard cut.
@@ -0,0 +1,41 @@
## Context
RC3 uses one provider-bearing `SessionAuthority` for both the authenticated Server-to-gateway control plane and the gateway-to-client acknowledgement. Provider profile and identity are valid inputs to gateway provider work and release, but they are forbidden at the client boundary. Existing strict RC3 clients require those fields, so changing the client shape is intentionally incompatible.
## Goals / Non-Goals
**Goals:**
- Make provider disclosure structurally impossible in the client-facing authority type.
- Preserve the provider-bound Server-to-gateway admission, work, release, and cleanup contract.
- Produce strict, matching JSON Schema, Protobuf, Go, Rust, and Swift contracts.
**Non-Goals:**
- Supporting mixed RC3/RC4 gateway and client pairings.
- Changing `SessionAuthority`, `ProviderSessionWork`, `VERSION`, or global compatibility history.
- Adding response negotiation, optional provider fields, or permissive decoding.
## Decisions
1. Add `ClientSessionAuthority` with exactly `version`, `session_id`, `gateway_id`, `audience`, `reconnect_sequence`, `expires_at`, and `capabilities`. Reusing the common validation bounds keeps the new acknowledgement session-bound without representing provider data.
2. Keep the existing provider-bearing `SessionAuthority` unchanged for Server-to-gateway operations. Deleting its provider fields would broaden the security-sensitive change into Server admission and provider-work validation.
3. Treat RC4 as a coordinated hard cut. A dual decoder would still accept the forbidden RC3 shape and is unnecessary for an unreleased candidate.
4. Use the existing generator unchanged. The JSON Schema definition is sufficient to generate strict Go, Rust, and Swift types; the matching Protobuf message uses fields 1 through 7.
## Risks / Trade-offs
- [RC3 and RC4 clients are not wire-compatible] → Pin and qualify Server, gateway, and client as one exact RC4 set; retain RC3 as an immutable rollback set.
- [A future gateway could serialize the wrong authority type] → Consumer gateway tests must capture the raw acknowledgement and require `ClientSessionAuthority` with no provider-bearing keys.
- [Strict decoding rejects future additive fields] → Version a future client authority explicitly instead of weakening this v1 decoder.
## Migration Plan
1. Publish the verified immutable Protocol RC4 tag.
2. Repin Data, Server, and macOS to the exact RC4 commit.
3. Change gateway egress and client decoders together, then qualify the exact all-RC4 set.
4. Roll back only as the complete immutable RC3 set; do not retag or mix candidates.
## Open Questions
None for this pre-release hard cut. Evidence of deployed RC3 coexistence would require a separate negotiated-version design and blocks this migration model.
@@ -0,0 +1,23 @@
## Why
The gateway currently serializes the provider-bearing Server-to-gateway `SessionAuthority` to clients, crossing provider identity into a client trust boundary that forbids it. RC4 must make that boundary structural before the pre-release client set is qualified.
## What Changes
- Add a strict provider-free `ClientSessionAuthority` with the seven session, gateway, audience, reconnect, expiry, and capability fields shared with `SessionAuthority`.
- Keep `SessionAuthority` and `ProviderSessionWork` unchanged for the authenticated Server-to-gateway control plane.
- **BREAKING** Replace the gateway-to-client RC3 response shape with `ClientSessionAuthority` as a coordinated RC4 hard cut; no mixed RC3/RC4 compatibility is claimed.
## Capabilities
### New Capabilities
- `gateway-transport-and-admission`: Defines the distinct client-facing authority and its provider-free gateway admission boundary.
### Modified Capabilities
None.
## Impact
Protocol JSON Schema, tunnel Protobuf, generated Go/Rust/Swift bindings, and consumer Protocol pins advance together to `v1.0.0-phase3d-macos-rc.4`. `VERSION`, global compatibility history, and the Server-to-gateway provider authority remain unchanged.
@@ -0,0 +1,30 @@
## ADDED Requirements
### Requirement: Client-facing authority is provider-free
The gateway-to-client acknowledgement SHALL use `ClientSessionAuthority` version `"1"` containing exactly `version`, `session_id`, `gateway_id`, `audience`, `reconnect_sequence`, `expires_at`, and `capabilities`. The contract SHALL reject missing required fields, unknown fields including provider identities and routes, invalid or noncanonical expiry timestamps, and trailing JSON values.
#### Scenario: Gateway acknowledges an admitted client
- **WHEN** provider work succeeds and gateway and client capabilities intersect
- **THEN** the gateway returns a valid `ClientSessionAuthority` containing the selected capabilities and no provider-bearing field
#### Scenario: Client receives provider-bearing authority
- **WHEN** a client authority payload contains `provider_profile`, `provider_identity`, a provider route, or any unknown key
- **THEN** the strict client authority decoder rejects the payload
#### Scenario: Client receives incomplete or malformed authority
- **WHEN** a client authority omits any required binding, has an invalid expiry, or is followed by another JSON value
- **THEN** the strict client authority decoder rejects the payload
### Requirement: Server-to-gateway authority remains provider-bound
The authenticated Server-to-gateway control plane SHALL continue to use the existing provider-bearing `SessionAuthority` for admission, provider work, release, and cleanup. `SessionAuthority` and `ProviderSessionWork` fields and semantics MUST remain unchanged by this change.
#### Scenario: Gateway performs provider work
- **WHEN** the Server admits a gateway session and the gateway requests provider work
- **THEN** the original provider-bearing `SessionAuthority` continues to bind provider work and subsequent release or cleanup
### Requirement: RC4 is a coordinated hard cut
The RC4 gateway and client SHALL use `ClientSessionAuthority`; mixed RC3/RC4 gateway-client compatibility SHALL NOT be claimed. RC4 SHALL NOT add optional provider fields, a dual decoder, or response negotiation for RC3.
#### Scenario: RC4 candidate is qualified
- **WHEN** the Protocol RC4 tag is pinned by Server, gateway, and client
- **THEN** qualification uses only that exact coordinated set
@@ -0,0 +1,10 @@
## 1. Contract and regressions
- [x] 1.1 Add RED-first Go, Swift, Rust, and Protobuf regressions for the strict provider-free authority.
- [x] 1.2 Add the exact seven-field JSON Schema and Protobuf `ClientSessionAuthority` without changing existing authority contracts.
- [x] 1.3 Regenerate Go, Rust, Swift, Protobuf, and manifest outputs using repository tooling.
## 2. Verification
- [x] 2.1 Pass focused Go and generated-contract regressions.
- [x] 2.2 Pass strict OpenSpec validation, full `make verify`, second-generation cleanliness, and diff checks.
+10
View File
@@ -117,6 +117,16 @@ message SessionAuthority {
string provider_identity = 9;
}
message ClientSessionAuthority {
string version = 1;
string session_id = 2;
string gateway_id = 3;
string audience = 4;
uint64 reconnect_sequence = 5;
google.protobuf.Timestamp expires_at = 6;
CapabilityProfile capabilities = 7;
}
message ProviderSessionWork {
string version = 1;
string session_id = 2;
+14
View File
@@ -551,6 +551,20 @@
"provider_identity": {"type": "string", "minLength": 1, "maxLength": 256}
}
},
"ClientSessionAuthority": {
"type": "object",
"additionalProperties": false,
"required": ["version", "session_id", "gateway_id", "audience", "reconnect_sequence", "expires_at", "capabilities"],
"properties": {
"version": {"type": "string", "const": "1"},
"session_id": {"type": "string", "minLength": 1, "maxLength": 128},
"gateway_id": {"type": "string", "minLength": 1, "maxLength": 128},
"audience": {"type": "string", "minLength": 1, "maxLength": 256},
"reconnect_sequence": {"type": "integer", "minimum": 0},
"expires_at": {"type": "string", "format": "date-time", "maxLength": 64},
"capabilities": {"$ref": "#/$defs/CapabilityProfile"}
}
},
"ProviderStreamPolicy": {
"type": "object",
"additionalProperties": false,
+76
View File
@@ -3,6 +3,7 @@ package protocol_test
import (
"bytes"
"encoding/hex"
"encoding/json"
"reflect"
"strings"
"testing"
@@ -362,6 +363,81 @@ func TestSessionAuthorityRejectsProviderRoute(t *testing.T) {
}
}
func TestClientSessionAuthorityIsStrictAndProviderFree(t *testing.T) {
authority := protocol.ClientSessionAuthority{
Version: "1", SessionID: "session-1", GatewayID: "gateway-1", Audience: "versevdi-gateway",
ReconnectSequence: 2, ExpiresAt: "2099-01-01T00:00:00Z", Capabilities: protocol.CapabilityProfile{
Transport: "quic-tls13", Framing: "datagram-v1", Media: "encoded", Audio: "encoded",
SourceRateControl: "server", ClientDecode: []string{"h264-opus"},
},
}
encoded, err := protocol.EncodeClientSessionAuthority(authority)
if err != nil {
t.Fatalf("EncodeClientSessionAuthority() error = %v", err)
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(encoded, &fields); err != nil {
t.Fatalf("encoded client authority is not JSON: %v", err)
}
wantFields := map[string]bool{
"version": true, "session_id": true, "gateway_id": true, "audience": true,
"reconnect_sequence": true, "expires_at": true, "capabilities": true,
}
if len(fields) != len(wantFields) {
t.Fatalf("encoded client authority fields = %v; want exactly %v", fields, wantFields)
}
for field := range fields {
if !wantFields[field] {
t.Fatalf("encoded client authority contains forbidden field %q", field)
}
}
if bytes.Contains(encoded, []byte("provider_")) {
t.Fatalf("encoded client authority disclosed provider data: %s", encoded)
}
decoded, err := protocol.DecodeClientSessionAuthority(encoded)
if err != nil || !reflect.DeepEqual(decoded, authority) {
t.Fatalf("DecodeClientSessionAuthority() = %+v, %v; want %+v", decoded, err, authority)
}
for _, field := range []string{"version", "session_id", "gateway_id", "audience", "reconnect_sequence", "expires_at", "capabilities"} {
missing := make(map[string]json.RawMessage, len(fields)-1)
for key, value := range fields {
if key != field {
missing[key] = value
}
}
payload, err := json.Marshal(missing)
if err != nil {
t.Fatal(err)
}
if _, err := protocol.DecodeClientSessionAuthority(payload); err == nil {
t.Fatalf("DecodeClientSessionAuthority accepted missing %q", field)
}
}
for name, value := range map[string]string{
"provider_profile": `"apollo"`,
"provider_identity": `"provider-1"`,
"provider_url": `"https://provider.invalid"`,
"management_host": `"provider.invalid"`,
"unknown": `true`,
} {
payload := append(append([]byte(nil), encoded[:len(encoded)-1]...), []byte(`,"`+name+`":`+value+`}`)...)
if _, err := protocol.DecodeClientSessionAuthority(payload); err == nil {
t.Fatalf("DecodeClientSessionAuthority accepted injected %q", name)
}
}
for _, expiresAt := range []string{"not-a-time", "2099-01-01T00:00:00+00:00", "2099-01-01T00:00:00.100Z"} {
payload := bytes.Replace(encoded, []byte("2099-01-01T00:00:00Z"), []byte(expiresAt), 1)
if _, err := protocol.DecodeClientSessionAuthority(payload); err == nil {
t.Fatalf("DecodeClientSessionAuthority accepted expires_at %q", expiresAt)
}
}
if _, err := protocol.DecodeClientSessionAuthority(append(encoded, []byte(" {}")...)); err == nil {
t.Fatal("DecodeClientSessionAuthority accepted trailing JSON")
}
}
func TestProviderSessionWorkIsStrictAndSessionBound(t *testing.T) {
valid := `{"version":"1","session_id":"session-1","gateway_id":"gateway-1","reconnect_sequence":0,"expires_at":"2099-01-01T00:00:00Z","provider_profile":"apollo","provider_identity":"provider-1","policy_version_id":"policy-1","stream_policy":{"resolution_width":2560,"resolution_height":1440,"fps":120,"codec":"HEVC","bitrate_kbps":40000,"audio_enabled":true},"application_id":"42","client_id":"paired-client-1","management_host":"apollo.test","management_port":47990,"stream_host":"apollo.test","stream_port":47984,"client_certificate_pem":"certificate","client_private_key_pem":"private-key","server_certificate_pem":"server-certificate","clipboard_policy":{"client_to_provider_enabled":false,"provider_to_client_enabled":false,"max_text_bytes":65536,"max_updates_per_minute":30},"provider_application_termination_allowed":false}`
if _, err := protocol.DecodeProviderSessionWork([]byte(valid)); err != nil {
+98
View File
@@ -4,6 +4,7 @@
from __future__ import annotations
import pathlib
import re
import shutil
import subprocess
import tempfile
@@ -24,6 +25,32 @@ def run_failure(command: list[str], directory: pathlib.Path, expected: str) -> N
raise RuntimeError("expected failure: %s\n%s%s" % (" ".join(command), result.stdout, result.stderr))
def protobuf_message_fields(name: str) -> list[tuple[str, int]]:
result = subprocess.run(
["protoc", "--decode=google.protobuf.FileDescriptorSet", "google/protobuf/descriptor.proto"],
input=(ROOT / "gen/protobuf/tunnel-v1.pb").read_bytes(),
capture_output=True,
check=False,
)
if result.returncode != 0:
raise RuntimeError(result.stderr.decode())
lines = result.stdout.decode().splitlines()
marker = f' name: "{name}"'
try:
name_index = lines.index(marker)
start = max(index for index in range(name_index) if lines[index] == " message_type {")
except (ValueError, StopIteration) as exc:
raise RuntimeError(f"protobuf descriptor missing message {name}") from exc
depth = 0
block: list[str] = []
for line in lines[start:]:
depth += line.count("{") - line.count("}")
block.append(line)
if depth == 0:
break
return [(field, int(number)) for field, number in re.findall(r' field \{\n name: "([^"]+)"\n number: (\d+)', "\n".join(block))]
def main() -> int:
with tempfile.TemporaryDirectory(prefix="versevdi-generated-contracts-") as temporary:
workspace = pathlib.Path(temporary)
@@ -136,6 +163,52 @@ for invalid in [
fatalError("invalid tunnel admission request was accepted")
} catch { }
}
let clientAuthority = try ClientSessionAuthority(
version: "1", sessionId: "session", gatewayId: "gateway", audience: "audience",
reconnectSequence: 2, expiresAt: "2099-01-01T00:00:00Z", capabilities: capability
)
let clientAuthorityJSON = try clientAuthority.encodeJSON()
let clientAuthorityObject = try JSONSerialization.jsonObject(with: clientAuthorityJSON) as! [String: Any]
guard Set(clientAuthorityObject.keys) == Set([
"version", "session_id", "gateway_id", "audience", "reconnect_sequence", "expires_at", "capabilities"
]), !String(data: clientAuthorityJSON, encoding: .utf8)!.contains("provider_") else {
fatalError("client authority was not exactly provider-free")
}
_ = try ClientSessionAuthority.decodeJSON(clientAuthorityJSON)
for field in ["version", "session_id", "gateway_id", "audience", "reconnect_sequence", "expires_at", "capabilities"] {
var missing = clientAuthorityObject
missing.removeValue(forKey: field)
do {
_ = try ClientSessionAuthority.decodeJSON(try JSONSerialization.data(withJSONObject: missing))
fatalError("client authority accepted missing \(field)")
} catch { }
}
for (field, value) in [
("provider_profile", "apollo"),
("provider_identity", "provider-1"),
("provider_url", "https://provider.invalid"),
("management_host", "provider.invalid"),
("unknown", "true"),
] {
var injected = clientAuthorityObject
injected[field] = value
do {
_ = try ClientSessionAuthority.decodeJSON(try JSONSerialization.data(withJSONObject: injected))
fatalError("client authority accepted injected \(field)")
} catch { }
}
for expiresAt in ["not-a-time", "2099-01-01T00:00:00+00:00", "2099-01-01T00:00:00.100Z"] {
var invalidExpiry = clientAuthorityObject
invalidExpiry["expires_at"] = expiresAt
do {
_ = try ClientSessionAuthority.decodeJSON(try JSONSerialization.data(withJSONObject: invalidExpiry))
fatalError("client authority accepted invalid expiry")
} catch { }
}
do {
_ = try ClientSessionAuthority.decodeJSON(clientAuthorityJSON + Data(" {}".utf8))
fatalError("client authority accepted trailing JSON")
} catch { }
do {
_ = try AllocationPolicy(
minimumKbps: 100, targetKbps: 50, maximumKbps: 25, tier: "standard",
@@ -348,6 +421,16 @@ fn main() {
"1".into(), "session".into(), "gateway".into(), "audience".into(),
"g".repeat(43), 0, "short".into(), "s".repeat(86), capabilities.clone(),
).is_err());
let client_authority = ClientSessionAuthority::new(
"1".into(), "session".into(), "gateway".into(), "audience".into(), 2,
"2099-01-01T00:00:00Z".into(), capabilities.clone(),
).unwrap();
assert_eq!(client_authority.sessionId(), "session");
assert_eq!(client_authority.capabilities(), &capabilities);
assert!(ClientSessionAuthority::new(
"1".into(), "session".into(), "gateway".into(), "audience".into(), 2,
"not-a-time".into(), capabilities.clone(),
).is_err());
assert!(intersect_capability_profiles(&[capabilities.clone(), capabilities.clone()]).is_ok());
let incompatible = CapabilityProfile::new(
"quic-tls13".into(), "datagram-v1".into(), "encoded".into(),
@@ -441,6 +524,21 @@ fn main() {
)
run(["rustc", str(rust), "-o", str(workspace / "rust-contracts")], ROOT)
run([str(workspace / "rust-contracts")], ROOT)
expected_protobuf_fields = [
("version", 1),
("session_id", 2),
("gateway_id", 3),
("audience", 4),
("reconnect_sequence", 5),
("expires_at", 6),
("capabilities", 7),
]
actual_protobuf_fields = protobuf_message_fields("ClientSessionAuthority")
if actual_protobuf_fields != expected_protobuf_fields:
raise RuntimeError(
f"ClientSessionAuthority protobuf fields = {actual_protobuf_fields}; "
f"want {expected_protobuf_fields}"
)
rust_unknown = workspace / "unknown.rs"
shutil.copyfile(ROOT / "gen/rust/protocol.rs", rust_unknown)
with rust_unknown.open("a", encoding="utf-8") as output: