Compare commits

...
Author SHA1 Message Date
sechmachine 4693102b3c Protocol: require canonical client authority expiry
Verify Protocol / verify (push) Successful in 1m2s
Verify Protocol / module (push) Successful in 1m41s
2026-08-12 12:03:36 +07:00
sechmachine afbcea62f9 Protocol: split client session authority
Verify Protocol / verify (push) Successful in 1m2s
Verify Protocol / module (push) Successful in 1m45s
2026-08-12 11:50:41 +07:00
sechmachine b6a4f773e4 Protocol: freeze device proof and browser CSRF contracts
Verify Protocol / module (push) Successful in 1m12s
Verify Protocol / verify (push) Successful in 22s
2026-08-11 21:21:28 +07:00
22 changed files with 867 additions and 9 deletions
+2
View File
@@ -0,0 +1,2 @@
id version kind input expected
device-proof-canonical 1 device_proof_transcript server_id=00112233445566778899aabbccddeeff;principal_id=102132435465768798a9bacbdcedfe0f;device_id=ffeeddccbbaa99887766554433221100;challenge=000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f;expiry_unix_ms=1700000000123 76657273657664692d6465766963652d70726f6f662d763100112233445566778899aabbccddeeff102132435465768798a9bacbdcedfe0fffeeddccbbaa99887766554433221100000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f0000018bcfe5687b
1 id version kind input expected
2 device-proof-canonical 1 device_proof_transcript server_id=00112233445566778899aabbccddeeff;principal_id=102132435465768798a9bacbdcedfe0f;device_id=ffeeddccbbaa99887766554433221100;challenge=000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f;expiry_unix_ms=1700000000123 76657273657664692d6465766963652d70726f6f662d763100112233445566778899aabbccddeeff102132435465768798a9bacbdcedfe0fffeeddccbbaa99887766554433221100000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f0000018bcfe5687b
+2 -1
View File
@@ -4,11 +4,12 @@
"fixtures/conformance/control-v1.tsv", "fixtures/conformance/control-v1.tsv",
"fixtures/conformance/datagram-v1.tsv", "fixtures/conformance/datagram-v1.tsv",
"fixtures/conformance/datagram-v2.tsv", "fixtures/conformance/datagram-v2.tsv",
"fixtures/conformance/device-proof-v1.tsv",
"fixtures/conformance/events-v1.tsv", "fixtures/conformance/events-v1.tsv",
"fixtures/conformance/gateway-clipboard-audit-v1.tsv", "fixtures/conformance/gateway-clipboard-audit-v1.tsv",
"fixtures/conformance/gateway-clipboard-v1.tsv", "fixtures/conformance/gateway-clipboard-v1.tsv",
"fixtures/conformance/gateway-input-feedback-v1.tsv", "fixtures/conformance/gateway-input-feedback-v1.tsv",
"fixtures/conformance/tunnel-v1.tsv" "fixtures/conformance/tunnel-v1.tsv"
], ],
"corpus_sha256": "ed69937656f395b30f520861948f82ed3c0b21ea86e9b33c7949ed09942e701d" "corpus_sha256": "6d2ce3a855b2fa45733a5f7b5b4c2e68448cceed5dfbca535ec81fe8cf230b30"
} }
+152 -1
View File
@@ -4,6 +4,7 @@ package protocol
import ( import (
"bytes" "bytes"
"encoding/base64" "encoding/base64"
"encoding/binary"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -13,7 +14,7 @@ import (
"time" "time"
) )
const SchemaSHA256 = "dea3dd210c53d5a2d37050dd6afd8b0ac5bb8edcb7ab25a02e4026489ce8a00f" const SchemaSHA256 = "b2353c12269304289b4e872f27cc370ae61b958dea90d9fb7b6ab8afd7d37248"
const ProtocolVersion = "1.0.0" const ProtocolVersion = "1.0.0"
const CurrentWireVersion = "2" const CurrentWireVersion = "2"
const NMinus1WireVersion = "1" const NMinus1WireVersion = "1"
@@ -96,6 +97,16 @@ type ChannelFrame struct {
Payload string `json:"payload"` Payload string `json:"payload"`
} }
type ClientSessionAuthority struct {
Version string `json:"version"`
SessionID string `json:"session_id"`
GatewayID string `json:"gateway_id"`
Audience string `json:"audience"`
ReconnectSequence int64 `json:"reconnect_sequence"`
ExpiresAt string `json:"expires_at"`
Capabilities CapabilityProfile `json:"capabilities"`
}
type ClipboardPolicy struct { type ClipboardPolicy struct {
ClientToProviderEnabled bool `json:"client_to_provider_enabled"` ClientToProviderEnabled bool `json:"client_to_provider_enabled"`
ProviderToClientEnabled bool `json:"provider_to_client_enabled"` ProviderToClientEnabled bool `json:"provider_to_client_enabled"`
@@ -1232,6 +1243,122 @@ func EncodeChannelFrame(value ChannelFrame) ([]byte, error) {
return json.Marshal(value) return json.Marshal(value)
} }
func (v ClientSessionAuthority) Validate() error {
var violations []FieldViolation
if v.Version == "" {
violations = append(violations, FieldViolation{Field: "version", Code: "required"})
}
if v.Version != "1" && v.Version != "" {
violations = append(violations, FieldViolation{Field: "version", Code: "invalid_value"})
}
if v.SessionID == "" {
violations = append(violations, FieldViolation{Field: "session_id", Code: "required"})
}
if len(v.SessionID) < 1 && v.SessionID != "" {
violations = append(violations, FieldViolation{Field: "session_id", Code: "min_length"})
}
if len(v.SessionID) > 128 {
violations = append(violations, FieldViolation{Field: "session_id", Code: "max_length"})
}
if v.GatewayID == "" {
violations = append(violations, FieldViolation{Field: "gateway_id", Code: "required"})
}
if len(v.GatewayID) < 1 && v.GatewayID != "" {
violations = append(violations, FieldViolation{Field: "gateway_id", Code: "min_length"})
}
if len(v.GatewayID) > 128 {
violations = append(violations, FieldViolation{Field: "gateway_id", Code: "max_length"})
}
if v.Audience == "" {
violations = append(violations, FieldViolation{Field: "audience", Code: "required"})
}
if len(v.Audience) < 1 && v.Audience != "" {
violations = append(violations, FieldViolation{Field: "audience", Code: "min_length"})
}
if len(v.Audience) > 256 {
violations = append(violations, FieldViolation{Field: "audience", Code: "max_length"})
}
if v.ReconnectSequence != 0 && v.ReconnectSequence < 0 {
violations = append(violations, FieldViolation{Field: "reconnect_sequence", Code: "minimum"})
}
if v.ExpiresAt == "" {
violations = append(violations, FieldViolation{Field: "expires_at", Code: "required"})
}
if len(v.ExpiresAt) > 64 {
violations = append(violations, FieldViolation{Field: "expires_at", Code: "max_length"})
}
if v.ExpiresAt != "" {
if parsed, err := time.Parse(time.RFC3339Nano, v.ExpiresAt); err != nil || parsed.UTC().Format(time.RFC3339Nano) != v.ExpiresAt {
violations = append(violations, FieldViolation{Field: "expires_at", Code: "invalid_time"})
}
}
if reflect.DeepEqual(v.Capabilities, CapabilityProfile{}) {
violations = append(violations, FieldViolation{Field: "capabilities", Code: "required"})
}
if err := v.Capabilities.Validate(); err != nil {
violations = append(violations, FieldViolation{Field: "capabilities", Code: "invalid_object"})
}
if len(violations) > 0 {
return ValidationError{Violations: violations}
}
return nil
}
func DecodeClientSessionAuthority(data []byte) (ClientSessionAuthority, error) {
var value ClientSessionAuthority
if len(data) > 1024*1024 {
return value, errors.New("protocol payload exceeds limit")
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(data, &fields); err != nil {
return value, err
}
if raw, ok := fields["audience"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return value, ValidationError{Violations: []FieldViolation{{Field: "audience", Code: "required"}}}
}
if raw, ok := fields["capabilities"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return value, ValidationError{Violations: []FieldViolation{{Field: "capabilities", Code: "required"}}}
}
if raw, ok := fields["expires_at"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return value, ValidationError{Violations: []FieldViolation{{Field: "expires_at", Code: "required"}}}
}
if raw, ok := fields["gateway_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return value, ValidationError{Violations: []FieldViolation{{Field: "gateway_id", Code: "required"}}}
}
if raw, ok := fields["reconnect_sequence"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return value, ValidationError{Violations: []FieldViolation{{Field: "reconnect_sequence", Code: "required"}}}
}
if raw, ok := fields["session_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return value, ValidationError{Violations: []FieldViolation{{Field: "session_id", Code: "required"}}}
}
if raw, ok := fields["version"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return value, ValidationError{Violations: []FieldViolation{{Field: "version", Code: "required"}}}
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&value); err != nil {
return value, err
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
if err == nil {
return value, errors.New("trailing JSON value")
}
return value, err
}
if err := value.Validate(); err != nil {
return value, err
}
return value, nil
}
func EncodeClientSessionAuthority(value ClientSessionAuthority) ([]byte, error) {
if err := value.Validate(); err != nil {
return nil, err
}
return json.Marshal(value)
}
func (v ClipboardPolicy) Validate() error { func (v ClipboardPolicy) Validate() error {
var violations []FieldViolation var violations []FieldViolation
if v.MaxTextBytes == 0 { if v.MaxTextBytes == 0 {
@@ -5564,6 +5691,30 @@ func EncodeVersionNegotiation(value VersionNegotiation) ([]byte, error) {
return json.Marshal(value) return json.Marshal(value)
} }
func DeviceRegistrationProofTranscript(serverID, principalID, deviceID, challenge []byte, expiryUnixMilliseconds int64) ([]byte, error) {
for _, value := range []struct {
field string
bytes []byte
length int
}{{"server_id", serverID, 16}, {"principal_id", principalID, 16}, {"device_id", deviceID, 16}, {"challenge", challenge, 32}} {
if len(value.bytes) != value.length {
return nil, ValidationError{Violations: []FieldViolation{{Field: value.field, Code: "invalid_length"}}}
}
}
if expiryUnixMilliseconds < 0 {
return nil, ValidationError{Violations: []FieldViolation{{Field: "expiry_unix_milliseconds", Code: "minimum"}}}
}
transcript := make([]byte, 0, 112)
transcript = append(transcript, "versevdi-device-proof-v1"...)
transcript = append(transcript, serverID...)
transcript = append(transcript, principalID...)
transcript = append(transcript, deviceID...)
transcript = append(transcript, challenge...)
var expiry [8]byte
binary.BigEndian.PutUint64(expiry[:], uint64(expiryUnixMilliseconds))
return append(transcript, expiry[:]...), nil
}
var ErrNoCapabilityOverlap = errors.New("no capability overlap") var ErrNoCapabilityOverlap = errors.New("no capability overlap")
func IntersectCapabilityProfiles(profiles ...CapabilityProfile) (CapabilityProfile, error) { func IntersectCapabilityProfiles(profiles ...CapabilityProfile) (CapabilityProfile, error) {
+2 -2
View File
@@ -12,7 +12,7 @@
"3" "3"
] ]
}, },
"generator_sha256": "8a153cf1e99682d010ff91c754ef056c64aece8f8bbca0ca58f8eef2b9039119", "generator_sha256": "00c1905fc611ca9e226cd90da761b48b8e203734b10542befea397a30082d360",
"protocol_version": "1.0.0", "protocol_version": "1.0.0",
"schema_sha256": "dea3dd210c53d5a2d37050dd6afd8b0ac5bb8edcb7ab25a02e4026489ce8a00f" "schema_sha256": "b2353c12269304289b4e872f27cc370ae61b958dea90d9fb7b6ab8afd7d37248"
} }
Binary file not shown.
+59 -1
View File
@@ -1,6 +1,6 @@
// Code generated by tools/generate.py; DO NOT EDIT. // Code generated by tools/generate.py; DO NOT EDIT.
#![allow(non_snake_case)] #![allow(non_snake_case)]
pub const SCHEMA_SHA256: &str = "dea3dd210c53d5a2d37050dd6afd8b0ac5bb8edcb7ab25a02e4026489ce8a00f"; pub const SCHEMA_SHA256: &str = "b2353c12269304289b4e872f27cc370ae61b958dea90d9fb7b6ab8afd7d37248";
pub const CURRENT_WIRE_VERSION: &str = "2"; pub const CURRENT_WIRE_VERSION: &str = "2";
pub const N_MINUS_1_WIRE_VERSION: &str = "1"; pub const N_MINUS_1_WIRE_VERSION: &str = "1";
pub const N_MINUS_2_WIRE_VERSION: &str = "0"; pub const N_MINUS_2_WIRE_VERSION: &str = "0";
@@ -353,6 +353,49 @@ impl ChannelFrame {
pub fn payload(&self) -> &String { &self.payload } pub fn payload(&self) -> &String { &self.payload }
} }
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientSessionAuthority {
version: String,
sessionId: String,
gatewayId: String,
audience: String,
reconnectSequence: i64,
expiresAt: String,
capabilities: CapabilityProfile,
}
impl ClientSessionAuthority {
pub fn new(version: String, sessionId: String, gatewayId: String, audience: String, reconnectSequence: i64, expiresAt: String, capabilities: CapabilityProfile) -> Result<Self, ValidationError> {
let value = Self { version, sessionId, gatewayId, audience, reconnectSequence, expiresAt, capabilities };
value.validate()?;
Ok(value)
}
pub fn validate(&self) -> Result<(), ValidationError> {
if self.version != "1" { return Err(ValidationError::new("version", "invalid_value")); }
if self.sessionId.is_empty() { return Err(ValidationError::new("session_id", "required")); }
if !self.sessionId.is_empty() && self.sessionId.len() < 1 { return Err(ValidationError::new("session_id", "min_length")); }
if self.sessionId.len() > 128 { return Err(ValidationError::new("session_id", "max_length")); }
if self.gatewayId.is_empty() { return Err(ValidationError::new("gateway_id", "required")); }
if !self.gatewayId.is_empty() && self.gatewayId.len() < 1 { return Err(ValidationError::new("gateway_id", "min_length")); }
if self.gatewayId.len() > 128 { return Err(ValidationError::new("gateway_id", "max_length")); }
if self.audience.is_empty() { return Err(ValidationError::new("audience", "required")); }
if !self.audience.is_empty() && self.audience.len() < 1 { return Err(ValidationError::new("audience", "min_length")); }
if self.audience.len() > 256 { return Err(ValidationError::new("audience", "max_length")); }
if self.reconnectSequence < 0 { return Err(ValidationError::new("reconnect_sequence", "minimum")); }
if self.expiresAt.len() > 64 { return Err(ValidationError::new("expires_at", "max_length")); }
if !valid_rfc3339_utc(self.expiresAt.as_str()) { return Err(ValidationError::new("expires_at", "invalid_time")); }
self.capabilities.validate().map_err(|_| ValidationError::new("capabilities", "invalid_object"))?;
Ok(())
}
pub fn version(&self) -> &String { &self.version }
pub fn sessionId(&self) -> &String { &self.sessionId }
pub fn gatewayId(&self) -> &String { &self.gatewayId }
pub fn audience(&self) -> &String { &self.audience }
pub fn reconnectSequence(&self) -> &i64 { &self.reconnectSequence }
pub fn expiresAt(&self) -> &String { &self.expiresAt }
pub fn capabilities(&self) -> &CapabilityProfile { &self.capabilities }
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClipboardPolicy { pub struct ClipboardPolicy {
clientToProviderEnabled: bool, clientToProviderEnabled: bool,
@@ -1976,6 +2019,21 @@ impl VersionNegotiation {
pub fn features(&self) -> &Vec<String> { &self.features } pub fn features(&self) -> &Vec<String> { &self.features }
} }
pub fn device_registration_proof_transcript(server_id: &[u8], principal_id: &[u8], device_id: &[u8], challenge: &[u8], expiry_unix_milliseconds: i64) -> Result<Vec<u8>, ValidationError> {
for (field, value, length) in [("server_id", server_id, 16), ("principal_id", principal_id, 16), ("device_id", device_id, 16), ("challenge", challenge, 32)] {
if value.len() != length { return Err(ValidationError::new(field, "invalid_length")); }
}
if expiry_unix_milliseconds < 0 { return Err(ValidationError::new("expiry_unix_milliseconds", "minimum")); }
let mut transcript = Vec::with_capacity(112);
transcript.extend_from_slice(b"versevdi-device-proof-v1");
transcript.extend_from_slice(server_id);
transcript.extend_from_slice(principal_id);
transcript.extend_from_slice(device_id);
transcript.extend_from_slice(challenge);
transcript.extend_from_slice(&(expiry_unix_milliseconds as u64).to_be_bytes());
Ok(transcript)
}
pub fn intersect_capability_profiles(profiles: &[CapabilityProfile]) -> Result<CapabilityProfile, ValidationError> { pub fn intersect_capability_profiles(profiles: &[CapabilityProfile]) -> Result<CapabilityProfile, ValidationError> {
let mut selected = profiles.first().ok_or_else(|| ValidationError::new("capabilities", "no_overlap"))?.clone(); let mut selected = profiles.first().ok_or_else(|| ValidationError::new("capabilities", "no_overlap"))?.clone();
selected.validate().map_err(|_| ValidationError::new("capabilities", "no_overlap"))?; selected.validate().map_err(|_| ValidationError::new("capabilities", "no_overlap"))?;
+73 -1
View File
@@ -1,7 +1,7 @@
// Code generated by tools/generate.py; DO NOT EDIT. // Code generated by tools/generate.py; DO NOT EDIT.
import Foundation import Foundation
public typealias JSONObject = [String: String] public typealias JSONObject = [String: String]
public let schemaSHA256 = "dea3dd210c53d5a2d37050dd6afd8b0ac5bb8edcb7ab25a02e4026489ce8a00f" public let schemaSHA256 = "b2353c12269304289b4e872f27cc370ae61b958dea90d9fb7b6ab8afd7d37248"
public let currentWireVersion = "2" public let currentWireVersion = "2"
public let nMinus1WireVersion = "1" public let nMinus1WireVersion = "1"
public let nMinus2WireVersion = "0" 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 func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }
} }
public struct ClientSessionAuthority: Codable, Equatable {
public let version: String
public let sessionId: String
public let gatewayId: String
public let audience: String
public let reconnectSequence: Int64
public let expiresAt: String
public let capabilities: CapabilityProfile
enum CodingKeys: String, CodingKey {
case version = "version"
case sessionId = "session_id"
case gatewayId = "gateway_id"
case audience = "audience"
case reconnectSequence = "reconnect_sequence"
case expiresAt = "expires_at"
case capabilities = "capabilities"
}
public init(version: String, sessionId: String, gatewayId: String, audience: String, reconnectSequence: Int64, expiresAt: String, capabilities: CapabilityProfile) throws {
self.version = version
self.sessionId = sessionId
self.gatewayId = gatewayId
self.audience = audience
self.reconnectSequence = reconnectSequence
self.expiresAt = expiresAt
self.capabilities = capabilities
try validate()
}
public init(from decoder: Decoder) throws {
let all = try decoder.container(keyedBy: AnyCodingKey.self)
for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") }
let c = try decoder.container(keyedBy: CodingKeys.self)
try self.init(version: try c.decode(String.self, forKey: .version), sessionId: try c.decode(String.self, forKey: .sessionId), gatewayId: try c.decode(String.self, forKey: .gatewayId), audience: try c.decode(String.self, forKey: .audience), reconnectSequence: try c.decode(Int64.self, forKey: .reconnectSequence), expiresAt: try c.decode(String.self, forKey: .expiresAt), capabilities: try c.decode(CapabilityProfile.self, forKey: .capabilities))
}
public func validate() throws {
if self.version != "1" { throw ContractValidationError(field: "version", code: "invalid_value") }
if self.sessionId.isEmpty { throw ContractValidationError(field: "session_id", code: "required") }
if !self.sessionId.isEmpty && self.sessionId.utf8.count < 1 { throw ContractValidationError(field: "session_id", code: "min_length") }
if self.sessionId.utf8.count > 128 { throw ContractValidationError(field: "session_id", code: "max_length") }
if self.gatewayId.isEmpty { throw ContractValidationError(field: "gateway_id", code: "required") }
if !self.gatewayId.isEmpty && self.gatewayId.utf8.count < 1 { throw ContractValidationError(field: "gateway_id", code: "min_length") }
if self.gatewayId.utf8.count > 128 { throw ContractValidationError(field: "gateway_id", code: "max_length") }
if self.audience.isEmpty { throw ContractValidationError(field: "audience", code: "required") }
if !self.audience.isEmpty && self.audience.utf8.count < 1 { throw ContractValidationError(field: "audience", code: "min_length") }
if self.audience.utf8.count > 256 { throw ContractValidationError(field: "audience", code: "max_length") }
if self.reconnectSequence < 0 { throw ContractValidationError(field: "reconnect_sequence", code: "minimum") }
if self.expiresAt.utf8.count > 64 { throw ContractValidationError(field: "expires_at", code: "max_length") }
if !validRFC3339UTC(self.expiresAt) { throw ContractValidationError(field: "expires_at", code: "invalid_time") }
try self.capabilities.validate()
}
public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }
public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }
}
public struct ClipboardPolicy: Codable, Equatable { public struct ClipboardPolicy: Codable, Equatable {
public let clientToProviderEnabled: Bool public let clientToProviderEnabled: Bool
public let providerToClientEnabled: Bool public let providerToClientEnabled: Bool
@@ -2583,6 +2640,21 @@ public struct VersionNegotiation: Codable, Equatable {
public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) } public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }
} }
public func deviceRegistrationProofTranscript(serverID: Data, principalID: Data, deviceID: Data, challenge: Data, expiryUnixMilliseconds: Int64) throws -> Data {
for (field, value, length) in [("server_id", serverID, 16), ("principal_id", principalID, 16), ("device_id", deviceID, 16), ("challenge", challenge, 32)] {
if value.count != length { throw ContractValidationError(field: field, code: "invalid_length") }
}
if expiryUnixMilliseconds < 0 { throw ContractValidationError(field: "expiry_unix_milliseconds", code: "minimum") }
var transcript = Data("versevdi-device-proof-v1".utf8)
transcript.append(serverID)
transcript.append(principalID)
transcript.append(deviceID)
transcript.append(challenge)
var expiry = UInt64(expiryUnixMilliseconds).bigEndian
Swift.withUnsafeBytes(of: &expiry) { transcript.append(contentsOf: $0) }
return transcript
}
public extension TunnelAdmissionRequest { public extension TunnelAdmissionRequest {
func deviceAdmissionTranscript() -> Data { func deviceAdmissionTranscript() -> Data {
var fields = [sessionId, gatewayId, audience, grant, String(reconnectSequence), clientNonce, capabilities.transport, capabilities.framing, capabilities.media, capabilities.audio, capabilities.sourceRateControl, String(capabilities.clientDecode.count)] var fields = [sessionId, gatewayId, audience, grant, String(reconnectSequence), clientNonce, capabilities.transport, capabilities.framing, capabilities.media, capabilities.audio, capabilities.sourceRateControl, String(capabilities.clientDecode.count)]
+28
View File
@@ -92,6 +92,8 @@ paths:
operationId: issueReauthenticationGrant operationId: issueReauthenticationGrant
security: security:
- browserSession: [] - browserSession: []
browserCsrfCookie: []
browserCsrfHeader: []
requestBody: requestBody:
required: true required: true
content: content:
@@ -113,6 +115,8 @@ paths:
operationId: logoutSession operationId: logoutSession
security: security:
- browserSession: [] - browserSession: []
browserCsrfCookie: []
browserCsrfHeader: []
- nativeBearer: [] - nativeBearer: []
responses: responses:
'204': {description: Session revoked and browser cookies cleared.} '204': {description: Session revoked and browser cookies cleared.}
@@ -123,6 +127,8 @@ paths:
operationId: registerDevice operationId: registerDevice
security: security:
- browserSession: [] - browserSession: []
browserCsrfCookie: []
browserCsrfHeader: []
requestBody: requestBody:
required: true required: true
content: content:
@@ -144,6 +150,8 @@ paths:
operationId: proveDevice operationId: proveDevice
security: security:
- browserSession: [] - browserSession: []
browserCsrfCookie: []
browserCsrfHeader: []
parameters: parameters:
- $ref: '#/components/parameters/DeviceID' - $ref: '#/components/parameters/DeviceID'
requestBody: requestBody:
@@ -167,6 +175,8 @@ paths:
operationId: revokeDevice operationId: revokeDevice
security: security:
- browserSession: [] - browserSession: []
browserCsrfCookie: []
browserCsrfHeader: []
parameters: parameters:
- $ref: '#/components/parameters/DeviceID' - $ref: '#/components/parameters/DeviceID'
responses: responses:
@@ -199,6 +209,8 @@ paths:
description: Control wire version 2 endpoint. Legacy version-1 SessionRequest payloads containing client-supplied policy_snapshot are rejected. description: Control wire version 2 endpoint. Legacy version-1 SessionRequest payloads containing client-supplied policy_snapshot are rejected.
security: security:
- browserSession: [] - browserSession: []
browserCsrfCookie: []
browserCsrfHeader: []
- nativeBearer: [] - nativeBearer: []
parameters: parameters:
- $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/IdempotencyKey'
@@ -249,6 +261,8 @@ paths:
operationId: allocateBrokerSession operationId: allocateBrokerSession
security: security:
- browserSession: [] - browserSession: []
browserCsrfCookie: []
browserCsrfHeader: []
- nativeBearer: [] - nativeBearer: []
parameters: parameters:
- $ref: '#/components/parameters/SessionID' - $ref: '#/components/parameters/SessionID'
@@ -274,6 +288,8 @@ paths:
operationId: reconnectBrokerSession operationId: reconnectBrokerSession
security: security:
- browserSession: [] - browserSession: []
browserCsrfCookie: []
browserCsrfHeader: []
- nativeBearer: [] - nativeBearer: []
parameters: parameters:
- $ref: '#/components/parameters/SessionID' - $ref: '#/components/parameters/SessionID'
@@ -300,6 +316,8 @@ paths:
operationId: cancelBrokerSession operationId: cancelBrokerSession
security: security:
- browserSession: [] - browserSession: []
browserCsrfCookie: []
browserCsrfHeader: []
- nativeBearer: [] - nativeBearer: []
parameters: parameters:
- $ref: '#/components/parameters/SessionID' - $ref: '#/components/parameters/SessionID'
@@ -353,6 +371,16 @@ components:
type: apiKey type: apiKey
in: cookie in: cookie
name: versevdi_session name: versevdi_session
browserCsrfCookie:
type: apiKey
in: cookie
name: versevdi_csrf
description: Must be identical to X-CSRF-Token and is checked against Server session state.
browserCsrfHeader:
type: apiKey
in: header
name: X-CSRF-Token
description: Must be identical to the versevdi_csrf cookie and is checked against Server session state.
nativeBearer: nativeBearer:
type: http type: http
scheme: bearer scheme: bearer
@@ -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; string provider_identity = 9;
} }
message ClientSessionAuthority {
string version = 1;
string session_id = 2;
string gateway_id = 3;
string audience = 4;
uint64 reconnect_sequence = 5;
google.protobuf.Timestamp expires_at = 6;
CapabilityProfile capabilities = 7;
}
message ProviderSessionWork { message ProviderSessionWork {
string version = 1; string version = 1;
string session_id = 2; string session_id = 2;
+14
View File
@@ -551,6 +551,20 @@
"provider_identity": {"type": "string", "minLength": 1, "maxLength": 256} "provider_identity": {"type": "string", "minLength": 1, "maxLength": 256}
} }
}, },
"ClientSessionAuthority": {
"type": "object",
"additionalProperties": false,
"required": ["version", "session_id", "gateway_id", "audience", "reconnect_sequence", "expires_at", "capabilities"],
"properties": {
"version": {"type": "string", "const": "1"},
"session_id": {"type": "string", "minLength": 1, "maxLength": 128},
"gateway_id": {"type": "string", "minLength": 1, "maxLength": 128},
"audience": {"type": "string", "minLength": 1, "maxLength": 256},
"reconnect_sequence": {"type": "integer", "minimum": 0},
"expires_at": {"type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$", "maxLength": 64},
"capabilities": {"$ref": "#/$defs/CapabilityProfile"}
}
},
"ProviderStreamPolicy": { "ProviderStreamPolicy": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
+116
View File
@@ -2,6 +2,8 @@ package protocol_test
import ( import (
"bytes" "bytes"
"encoding/hex"
"encoding/json"
"reflect" "reflect"
"strings" "strings"
"testing" "testing"
@@ -9,6 +11,45 @@ import (
protocol "git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol/gen/go/protocol" protocol "git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol/gen/go/protocol"
) )
func TestDeviceRegistrationProofTranscriptIsCanonicalAndStrict(t *testing.T) {
serverID, _ := hex.DecodeString("00112233445566778899aabbccddeeff")
principalID, _ := hex.DecodeString("102132435465768798a9bacbdcedfe0f")
deviceID, _ := hex.DecodeString("ffeeddccbbaa99887766554433221100")
challenge, _ := hex.DecodeString("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
want, _ := hex.DecodeString("76657273657664692d6465766963652d70726f6f662d763100112233445566778899aabbccddeeff102132435465768798a9bacbdcedfe0fffeeddccbbaa99887766554433221100000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f0000018bcfe5687b")
got, err := protocol.DeviceRegistrationProofTranscript(serverID, principalID, deviceID, challenge, 1700000000123)
if err != nil || !bytes.Equal(got, want) {
t.Fatalf("DeviceRegistrationProofTranscript() = %x, %v; want %x", got, err, want)
}
tests := []struct {
name string
serverID, principalID, deviceID, challenge []byte
expiry int64
field, code string
}{
{"server-short", serverID[:15], principalID, deviceID, challenge, 0, "server_id", "invalid_length"},
{"server-long", append(append([]byte(nil), serverID...), 0), principalID, deviceID, challenge, 0, "server_id", "invalid_length"},
{"principal-short", serverID, principalID[:15], deviceID, challenge, 0, "principal_id", "invalid_length"},
{"principal-long", serverID, append(append([]byte(nil), principalID...), 0), deviceID, challenge, 0, "principal_id", "invalid_length"},
{"device-short", serverID, principalID, deviceID[:15], challenge, 0, "device_id", "invalid_length"},
{"device-long", serverID, principalID, append(append([]byte(nil), deviceID...), 0), challenge, 0, "device_id", "invalid_length"},
{"challenge-short", serverID, principalID, deviceID, challenge[:31], 0, "challenge", "invalid_length"},
{"challenge-long", serverID, principalID, deviceID, append(append([]byte(nil), challenge...), 0), 0, "challenge", "invalid_length"},
{"negative-expiry", serverID, principalID, deviceID, challenge, -1, "expiry_unix_milliseconds", "minimum"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := protocol.DeviceRegistrationProofTranscript(test.serverID, test.principalID, test.deviceID, test.challenge, test.expiry)
validation, ok := err.(protocol.ValidationError)
if !ok || len(validation.Violations) != 1 || validation.Violations[0] != (protocol.FieldViolation{Field: test.field, Code: test.code}) {
t.Fatalf("error = %#v; want %s/%s validation error", err, test.field, test.code)
}
})
}
}
func TestManifestRejectsForbiddenAndUnknownFields(t *testing.T) { func TestManifestRejectsForbiddenAndUnknownFields(t *testing.T) {
valid := `{"version":"1","purpose":"launch","session_id":"session-1","reconnect_sequence":0,"gateway":{"id":"gateway-1","addresses":["gateway.control.test:443"],"public_identity":"gateway.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"}` valid := `{"version":"1","purpose":"launch","session_id":"session-1","reconnect_sequence":0,"gateway":{"id":"gateway-1","addresses":["gateway.control.test:443"],"public_identity":"gateway.control.test"},"tunnel":{"versions":["verse-gateway-v1/1"],"features":["control.v1"]},"profile":{"id":"standard","bounds":{"minimum_kbps":1,"target_kbps":2,"maximum_kbps":3}},"grant":{"opaque_value":"opaque-one-time-grant-value-with-at-least-43-bytes","expires_at":"2099-01-01T00:00:00Z","audience":"versevdi-gateway"},"correlation_id":"correlation-1"}`
manifest, err := protocol.DecodeConnectionManifest([]byte(valid)) manifest, err := protocol.DecodeConnectionManifest([]byte(valid))
@@ -322,6 +363,81 @@ func TestSessionAuthorityRejectsProviderRoute(t *testing.T) {
} }
} }
func TestClientSessionAuthorityIsStrictAndProviderFree(t *testing.T) {
authority := protocol.ClientSessionAuthority{
Version: "1", SessionID: "session-1", GatewayID: "gateway-1", Audience: "versevdi-gateway",
ReconnectSequence: 2, ExpiresAt: "2099-01-01T00:00:00Z", Capabilities: protocol.CapabilityProfile{
Transport: "quic-tls13", Framing: "datagram-v1", Media: "encoded", Audio: "encoded",
SourceRateControl: "server", ClientDecode: []string{"h264-opus"},
},
}
encoded, err := protocol.EncodeClientSessionAuthority(authority)
if err != nil {
t.Fatalf("EncodeClientSessionAuthority() error = %v", err)
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(encoded, &fields); err != nil {
t.Fatalf("encoded client authority is not JSON: %v", err)
}
wantFields := map[string]bool{
"version": true, "session_id": true, "gateway_id": true, "audience": true,
"reconnect_sequence": true, "expires_at": true, "capabilities": true,
}
if len(fields) != len(wantFields) {
t.Fatalf("encoded client authority fields = %v; want exactly %v", fields, wantFields)
}
for field := range fields {
if !wantFields[field] {
t.Fatalf("encoded client authority contains forbidden field %q", field)
}
}
if bytes.Contains(encoded, []byte("provider_")) {
t.Fatalf("encoded client authority disclosed provider data: %s", encoded)
}
decoded, err := protocol.DecodeClientSessionAuthority(encoded)
if err != nil || !reflect.DeepEqual(decoded, authority) {
t.Fatalf("DecodeClientSessionAuthority() = %+v, %v; want %+v", decoded, err, authority)
}
for _, field := range []string{"version", "session_id", "gateway_id", "audience", "reconnect_sequence", "expires_at", "capabilities"} {
missing := make(map[string]json.RawMessage, len(fields)-1)
for key, value := range fields {
if key != field {
missing[key] = value
}
}
payload, err := json.Marshal(missing)
if err != nil {
t.Fatal(err)
}
if _, err := protocol.DecodeClientSessionAuthority(payload); err == nil {
t.Fatalf("DecodeClientSessionAuthority accepted missing %q", field)
}
}
for name, value := range map[string]string{
"provider_profile": `"apollo"`,
"provider_identity": `"provider-1"`,
"provider_url": `"https://provider.invalid"`,
"management_host": `"provider.invalid"`,
"unknown": `true`,
} {
payload := append(append([]byte(nil), encoded[:len(encoded)-1]...), []byte(`,"`+name+`":`+value+`}`)...)
if _, err := protocol.DecodeClientSessionAuthority(payload); err == nil {
t.Fatalf("DecodeClientSessionAuthority accepted injected %q", name)
}
}
for _, expiresAt := range []string{"not-a-time", "2099-01-01T00:00:00+00:00", "2099-01-01T00:00:00.100Z"} {
payload := bytes.Replace(encoded, []byte("2099-01-01T00:00:00Z"), []byte(expiresAt), 1)
if _, err := protocol.DecodeClientSessionAuthority(payload); err == nil {
t.Fatalf("DecodeClientSessionAuthority accepted expires_at %q", expiresAt)
}
}
if _, err := protocol.DecodeClientSessionAuthority(append(encoded, []byte(" {}")...)); err == nil {
t.Fatal("DecodeClientSessionAuthority accepted trailing JSON")
}
}
func TestProviderSessionWorkIsStrictAndSessionBound(t *testing.T) { func TestProviderSessionWorkIsStrictAndSessionBound(t *testing.T) {
valid := `{"version":"1","session_id":"session-1","gateway_id":"gateway-1","reconnect_sequence":0,"expires_at":"2099-01-01T00:00:00Z","provider_profile":"apollo","provider_identity":"provider-1","policy_version_id":"policy-1","stream_policy":{"resolution_width":2560,"resolution_height":1440,"fps":120,"codec":"HEVC","bitrate_kbps":40000,"audio_enabled":true},"application_id":"42","client_id":"paired-client-1","management_host":"apollo.test","management_port":47990,"stream_host":"apollo.test","stream_port":47984,"client_certificate_pem":"certificate","client_private_key_pem":"private-key","server_certificate_pem":"server-certificate","clipboard_policy":{"client_to_provider_enabled":false,"provider_to_client_enabled":false,"max_text_bytes":65536,"max_updates_per_minute":30},"provider_application_termination_allowed":false}` valid := `{"version":"1","session_id":"session-1","gateway_id":"gateway-1","reconnect_sequence":0,"expires_at":"2099-01-01T00:00:00Z","provider_profile":"apollo","provider_identity":"provider-1","policy_version_id":"policy-1","stream_policy":{"resolution_width":2560,"resolution_height":1440,"fps":120,"codec":"HEVC","bitrate_kbps":40000,"audio_enabled":true},"application_id":"42","client_id":"paired-client-1","management_host":"apollo.test","management_port":47990,"stream_host":"apollo.test","stream_port":47984,"client_certificate_pem":"certificate","client_private_key_pem":"private-key","server_certificate_pem":"server-certificate","clipboard_policy":{"client_to_provider_enabled":false,"provider_to_client_enabled":false,"max_text_bytes":65536,"max_updates_per_minute":30},"provider_application_termination_allowed":false}`
if _, err := protocol.DecodeProviderSessionWork([]byte(valid)); err != nil { if _, err := protocol.DecodeProviderSessionWork([]byte(valid)); err != nil {
+47
View File
@@ -187,6 +187,7 @@ def generate_go(defs: dict[str, dict[str, Any]], schema_hash: str, version: str,
"", "",
"import (", "import (",
"\"bytes\"", "\"bytes\"",
"\"encoding/binary\"",
"\"encoding/base64\"", "\"encoding/base64\"",
"\"encoding/json\"", "\"encoding/json\"",
"\"errors\"", "\"errors\"",
@@ -272,6 +273,22 @@ def generate_go(defs: dict[str, dict[str, Any]], schema_hash: str, version: str,
out.append("}") out.append("}")
out.append("") out.append("")
out.extend([ out.extend([
"func DeviceRegistrationProofTranscript(serverID, principalID, deviceID, challenge []byte, expiryUnixMilliseconds int64) ([]byte, error) {",
"\tfor _, value := range []struct { field string; bytes []byte; length int }{{\"server_id\", serverID, 16}, {\"principal_id\", principalID, 16}, {\"device_id\", deviceID, 16}, {\"challenge\", challenge, 32}} {",
"\t\tif len(value.bytes) != value.length { return nil, ValidationError{Violations: []FieldViolation{{Field: value.field, Code: \"invalid_length\"}}} }",
"\t}",
"\tif expiryUnixMilliseconds < 0 { return nil, ValidationError{Violations: []FieldViolation{{Field: \"expiry_unix_milliseconds\", Code: \"minimum\"}}} }",
"\ttranscript := make([]byte, 0, 112)",
"\ttranscript = append(transcript, \"versevdi-device-proof-v1\"...)",
"\ttranscript = append(transcript, serverID...)",
"\ttranscript = append(transcript, principalID...)",
"\ttranscript = append(transcript, deviceID...)",
"\ttranscript = append(transcript, challenge...)",
"\tvar expiry [8]byte",
"\tbinary.BigEndian.PutUint64(expiry[:], uint64(expiryUnixMilliseconds))",
"\treturn append(transcript, expiry[:]...), nil",
"}",
"",
"var ErrNoCapabilityOverlap = errors.New(\"no capability overlap\")", "var ErrNoCapabilityOverlap = errors.New(\"no capability overlap\")",
"", "",
"func IntersectCapabilityProfiles(profiles ...CapabilityProfile) (CapabilityProfile, error) {", "func IntersectCapabilityProfiles(profiles ...CapabilityProfile) (CapabilityProfile, error) {",
@@ -508,6 +525,21 @@ def generate_rust(defs: dict[str, dict[str, Any]], schema_hash: str, compatibili
]) ])
out.extend(["}", ""]) out.extend(["}", ""])
out.extend([ out.extend([
"pub fn device_registration_proof_transcript(server_id: &[u8], principal_id: &[u8], device_id: &[u8], challenge: &[u8], expiry_unix_milliseconds: i64) -> Result<Vec<u8>, ValidationError> {",
" for (field, value, length) in [(\"server_id\", server_id, 16), (\"principal_id\", principal_id, 16), (\"device_id\", device_id, 16), (\"challenge\", challenge, 32)] {",
" if value.len() != length { return Err(ValidationError::new(field, \"invalid_length\")); }",
" }",
" if expiry_unix_milliseconds < 0 { return Err(ValidationError::new(\"expiry_unix_milliseconds\", \"minimum\")); }",
" let mut transcript = Vec::with_capacity(112);",
" transcript.extend_from_slice(b\"versevdi-device-proof-v1\");",
" transcript.extend_from_slice(server_id);",
" transcript.extend_from_slice(principal_id);",
" transcript.extend_from_slice(device_id);",
" transcript.extend_from_slice(challenge);",
" transcript.extend_from_slice(&(expiry_unix_milliseconds as u64).to_be_bytes());",
" Ok(transcript)",
"}",
"",
"pub fn intersect_capability_profiles(profiles: &[CapabilityProfile]) -> Result<CapabilityProfile, ValidationError> {", "pub fn intersect_capability_profiles(profiles: &[CapabilityProfile]) -> Result<CapabilityProfile, ValidationError> {",
" let mut selected = profiles.first().ok_or_else(|| ValidationError::new(\"capabilities\", \"no_overlap\"))?.clone();", " let mut selected = profiles.first().ok_or_else(|| ValidationError::new(\"capabilities\", \"no_overlap\"))?.clone();",
" selected.validate().map_err(|_| ValidationError::new(\"capabilities\", \"no_overlap\"))?;", " selected.validate().map_err(|_| ValidationError::new(\"capabilities\", \"no_overlap\"))?;",
@@ -674,6 +706,21 @@ def generate_swift(defs: dict[str, dict[str, Any]], schema_hash: str, compatibil
out.extend(swift_validation(definition)) out.extend(swift_validation(definition))
out.extend([" }", "", " public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }", " public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }", "}", ""]) out.extend([" }", "", " public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }", " public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }", "}", ""])
out.extend([ out.extend([
"public func deviceRegistrationProofTranscript(serverID: Data, principalID: Data, deviceID: Data, challenge: Data, expiryUnixMilliseconds: Int64) throws -> Data {",
" for (field, value, length) in [(\"server_id\", serverID, 16), (\"principal_id\", principalID, 16), (\"device_id\", deviceID, 16), (\"challenge\", challenge, 32)] {",
" if value.count != length { throw ContractValidationError(field: field, code: \"invalid_length\") }",
" }",
" if expiryUnixMilliseconds < 0 { throw ContractValidationError(field: \"expiry_unix_milliseconds\", code: \"minimum\") }",
" var transcript = Data(\"versevdi-device-proof-v1\".utf8)",
" transcript.append(serverID)",
" transcript.append(principalID)",
" transcript.append(deviceID)",
" transcript.append(challenge)",
" var expiry = UInt64(expiryUnixMilliseconds).bigEndian",
" Swift.withUnsafeBytes(of: &expiry) { transcript.append(contentsOf: $0) }",
" return transcript",
"}",
"",
"public extension TunnelAdmissionRequest {", "public extension TunnelAdmissionRequest {",
" func deviceAdmissionTranscript() -> Data {", " func deviceAdmissionTranscript() -> Data {",
" var fields = [sessionId, gatewayId, audience, grant, String(reconnectSequence), clientNonce, capabilities.transport, capabilities.framing, capabilities.media, capabilities.audio, capabilities.sourceRateControl, String(capabilities.clientDecode.count)]", " var fields = [sessionId, gatewayId, audience, grant, String(reconnectSequence), clientNonce, capabilities.transport, capabilities.framing, capabilities.media, capabilities.audio, capabilities.sourceRateControl, String(capabilities.clientDecode.count)]",
+14
View File
@@ -58,6 +58,20 @@ func evaluate(version, kind, input string) string {
} }
} }
switch kind { switch kind {
case "device_proof_transcript":
serverID, serverErr := hex.DecodeString(parts["server_id"])
principalID, principalErr := hex.DecodeString(parts["principal_id"])
deviceID, deviceErr := hex.DecodeString(parts["device_id"])
challenge, challengeErr := hex.DecodeString(parts["challenge"])
expiry, expiryErr := strconv.ParseInt(parts["expiry_unix_ms"], 10, 64)
if serverErr != nil || principalErr != nil || deviceErr != nil || challengeErr != nil || expiryErr != nil {
return "invalid:fixture"
}
transcript, err := protocol.DeviceRegistrationProofTranscript(serverID, principalID, deviceID, challenge, expiry)
if err != nil {
return "invalid:device_proof"
}
return hex.EncodeToString(transcript)
case "version": case "version":
if input == "2" || input == "1" || input == "0" { if input == "2" || input == "1" || input == "0" {
return "valid" return "valid"
+19 -1
View File
@@ -129,6 +129,20 @@ fn evaluate(version: &str, kind: &str, input: &str) -> &'static str {
} }
} }
fn evaluate_device_proof(input: &str) -> String {
let values = values(input);
let server_id = decode_hex(values.get("server_id").map(String::as_str).unwrap_or_default()).expect("server fixture hex");
let principal_id = decode_hex(values.get("principal_id").map(String::as_str).unwrap_or_default()).expect("principal fixture hex");
let device_id = decode_hex(values.get("device_id").map(String::as_str).unwrap_or_default()).expect("device fixture hex");
let challenge = decode_hex(values.get("challenge").map(String::as_str).unwrap_or_default()).expect("challenge fixture hex");
let expiry = values.get("expiry_unix_ms").expect("expiry fixture").parse::<i64>().expect("expiry integer");
device_registration_proof_transcript(&server_id, &principal_id, &device_id, &challenge, expiry)
.expect("valid device proof fixture")
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
fn classify_gateway_input(encoded: &str) -> &'static str { fn classify_gateway_input(encoded: &str) -> &'static str {
let raw = match decode_hex(encoded) { let raw = match decode_hex(encoded) {
Some(raw) => raw, Some(raw) => raw,
@@ -333,7 +347,11 @@ fn main() {
for line in lines { for line in lines {
let fields: Vec<&str> = line.split('\t').collect(); let fields: Vec<&str> = line.split('\t').collect();
assert_eq!(fields.len(), 5); assert_eq!(fields.len(), 5);
let actual = evaluate(fields[1], fields[2], fields[3]); let actual = if fields[2] == "device_proof_transcript" {
evaluate_device_proof(fields[3])
} else {
evaluate(fields[1], fields[2], fields[3]).to_owned()
};
assert_eq!(actual, fields[4], "{}", fields[0]); assert_eq!(actual, fields[4], "{}", fields[0]);
results.push(format!("{}\t{}", fields[0], actual)); results.push(format!("{}\t{}", fields[0], actual));
} }
+16 -1
View File
@@ -81,6 +81,19 @@ func evaluate(_ version: String, _ kind: String, _ input: String) -> String {
} }
} }
func evaluateDeviceProof(_ input: String) -> String {
let values = values(input)
let serverID = Data(decodeHex(values["server_id"] ?? "")!)
let principalID = Data(decodeHex(values["principal_id"] ?? "")!)
let deviceID = Data(decodeHex(values["device_id"] ?? "")!)
let challenge = Data(decodeHex(values["challenge"] ?? "")!)
let expiry = Int64(values["expiry_unix_ms"] ?? "")!
return try! deviceRegistrationProofTranscript(
serverID: serverID, principalID: principalID, deviceID: deviceID,
challenge: challenge, expiryUnixMilliseconds: expiry
).map { String(format: "%02x", $0) }.joined()
}
func decodeHex(_ encoded: String) -> [UInt8]? { func decodeHex(_ encoded: String) -> [UInt8]? {
let characters = Array(encoded) let characters = Array(encoded)
guard characters.count % 2 == 0 else { return nil } guard characters.count % 2 == 0 else { return nil }
@@ -229,7 +242,9 @@ struct ConformanceMain {
for line in lines { for line in lines {
let fields = line.split(separator: "\t", omittingEmptySubsequences: false).map(String.init) let fields = line.split(separator: "\t", omittingEmptySubsequences: false).map(String.init)
precondition(fields.count == 5) precondition(fields.count == 5)
let actual = evaluate(fields[1], fields[2], fields[3]) let actual = fields[2] == "device_proof_transcript"
? evaluateDeviceProof(fields[3])
: evaluate(fields[1], fields[2], fields[3])
precondition(actual == fields[4], fields[0]) precondition(actual == fields[4], fields[0])
results.append("\(fields[0])\t\(actual)") results.append("\(fields[0])\t\(actual)")
} }
+162
View File
@@ -4,6 +4,7 @@
from __future__ import annotations from __future__ import annotations
import pathlib import pathlib
import re
import shutil import shutil
import subprocess import subprocess
import tempfile import tempfile
@@ -24,6 +25,32 @@ def run_failure(command: list[str], directory: pathlib.Path, expected: str) -> N
raise RuntimeError("expected failure: %s\n%s%s" % (" ".join(command), result.stdout, result.stderr)) raise RuntimeError("expected failure: %s\n%s%s" % (" ".join(command), result.stdout, result.stderr))
def protobuf_message_fields(name: str) -> list[tuple[str, int]]:
result = subprocess.run(
["protoc", "--decode=google.protobuf.FileDescriptorSet", "google/protobuf/descriptor.proto"],
input=(ROOT / "gen/protobuf/tunnel-v1.pb").read_bytes(),
capture_output=True,
check=False,
)
if result.returncode != 0:
raise RuntimeError(result.stderr.decode())
lines = result.stdout.decode().splitlines()
marker = f' name: "{name}"'
try:
name_index = lines.index(marker)
start = max(index for index in range(name_index) if lines[index] == " message_type {")
except (ValueError, StopIteration) as exc:
raise RuntimeError(f"protobuf descriptor missing message {name}") from exc
depth = 0
block: list[str] = []
for line in lines[start:]:
depth += line.count("{") - line.count("}")
block.append(line)
if depth == 0:
break
return [(field, int(number)) for field, number in re.findall(r' field \{\n name: "([^"]+)"\n number: (\d+)', "\n".join(block))]
def main() -> int: def main() -> int:
with tempfile.TemporaryDirectory(prefix="versevdi-generated-contracts-") as temporary: with tempfile.TemporaryDirectory(prefix="versevdi-generated-contracts-") as temporary:
workspace = pathlib.Path(temporary) workspace = pathlib.Path(temporary)
@@ -59,6 +86,44 @@ let transcript = "versevdi/tunnel-admission/v17:session7:gateway8:audience43:" +
guard String(data: request.deviceAdmissionTranscript(), encoding: .utf8) == transcript else { guard String(data: request.deviceAdmissionTranscript(), encoding: .utf8) == transcript else {
fatalError("unexpected device admission transcript") fatalError("unexpected device admission transcript")
} }
let proofServerID = Data(repeating: 1, count: 16)
let proofPrincipalID = Data(repeating: 2, count: 16)
let proofDeviceID = Data(repeating: 3, count: 16)
let proofChallenge = Data(repeating: 4, count: 32)
let proofTranscript = try deviceRegistrationProofTranscript(
serverID: proofServerID, principalID: proofPrincipalID, deviceID: proofDeviceID,
challenge: proofChallenge, expiryUnixMilliseconds: 1
)
guard proofTranscript.count == 112,
String(data: proofTranscript.prefix(24), encoding: .utf8) == "versevdi-device-proof-v1",
Array(proofTranscript.suffix(8)) == [0, 0, 0, 0, 0, 0, 0, 1] else {
fatalError("unexpected device registration proof transcript")
}
let invalidProofInputs: [(String, String, Data, Data, Data, Data, Int64)] = [
("server-short", "server_id", Data(repeating: 0, count: 15), proofPrincipalID, proofDeviceID, proofChallenge, 0),
("server-long", "server_id", Data(repeating: 0, count: 17), proofPrincipalID, proofDeviceID, proofChallenge, 0),
("principal-short", "principal_id", proofServerID, Data(repeating: 0, count: 15), proofDeviceID, proofChallenge, 0),
("principal-long", "principal_id", proofServerID, Data(repeating: 0, count: 17), proofDeviceID, proofChallenge, 0),
("device-short", "device_id", proofServerID, proofPrincipalID, Data(repeating: 0, count: 15), proofChallenge, 0),
("device-long", "device_id", proofServerID, proofPrincipalID, Data(repeating: 0, count: 17), proofChallenge, 0),
("challenge-short", "challenge", proofServerID, proofPrincipalID, proofDeviceID, Data(repeating: 0, count: 31), 0),
("challenge-long", "challenge", proofServerID, proofPrincipalID, proofDeviceID, Data(repeating: 0, count: 33), 0),
("negative-expiry", "expiry_unix_milliseconds", proofServerID, proofPrincipalID, proofDeviceID, proofChallenge, -1),
]
for (name, field, serverID, principalID, deviceID, challenge, expiry) in invalidProofInputs {
do {
_ = try deviceRegistrationProofTranscript(
serverID: serverID, principalID: principalID, deviceID: deviceID,
challenge: challenge, expiryUnixMilliseconds: expiry
)
fatalError("\(name) was accepted")
} catch let error as ContractValidationError {
guard error.field == field,
error.code == (field == "expiry_unix_milliseconds" ? "minimum" : "invalid_length") else {
fatalError("\(name) returned the wrong validation error")
}
}
}
let incompatible = try CapabilityProfile( let incompatible = try CapabilityProfile(
transport: "quic-tls13", framing: "datagram-v1", media: "encoded", transport: "quic-tls13", framing: "datagram-v1", media: "encoded",
audio: "encoded", sourceRateControl: "server", clientDecode: ["hevc-opus"] audio: "encoded", sourceRateControl: "server", clientDecode: ["hevc-opus"]
@@ -98,6 +163,52 @@ for invalid in [
fatalError("invalid tunnel admission request was accepted") fatalError("invalid tunnel admission request was accepted")
} catch { } } catch { }
} }
let clientAuthority = try ClientSessionAuthority(
version: "1", sessionId: "session", gatewayId: "gateway", audience: "audience",
reconnectSequence: 2, expiresAt: "2099-01-01T00:00:00Z", capabilities: capability
)
let clientAuthorityJSON = try clientAuthority.encodeJSON()
let clientAuthorityObject = try JSONSerialization.jsonObject(with: clientAuthorityJSON) as! [String: Any]
guard Set(clientAuthorityObject.keys) == Set([
"version", "session_id", "gateway_id", "audience", "reconnect_sequence", "expires_at", "capabilities"
]), !String(data: clientAuthorityJSON, encoding: .utf8)!.contains("provider_") else {
fatalError("client authority was not exactly provider-free")
}
_ = try ClientSessionAuthority.decodeJSON(clientAuthorityJSON)
for field in ["version", "session_id", "gateway_id", "audience", "reconnect_sequence", "expires_at", "capabilities"] {
var missing = clientAuthorityObject
missing.removeValue(forKey: field)
do {
_ = try ClientSessionAuthority.decodeJSON(try JSONSerialization.data(withJSONObject: missing))
fatalError("client authority accepted missing \(field)")
} catch { }
}
for (field, value) in [
("provider_profile", "apollo"),
("provider_identity", "provider-1"),
("provider_url", "https://provider.invalid"),
("management_host", "provider.invalid"),
("unknown", "true"),
] {
var injected = clientAuthorityObject
injected[field] = value
do {
_ = try ClientSessionAuthority.decodeJSON(try JSONSerialization.data(withJSONObject: injected))
fatalError("client authority accepted injected \(field)")
} catch { }
}
for expiresAt in ["not-a-time", "2099-01-01T00:00:00+00:00", "2099-01-01T00:00:00.100Z"] {
var invalidExpiry = clientAuthorityObject
invalidExpiry["expires_at"] = expiresAt
do {
_ = try ClientSessionAuthority.decodeJSON(try JSONSerialization.data(withJSONObject: invalidExpiry))
fatalError("client authority accepted invalid expiry")
} catch { }
}
do {
_ = try ClientSessionAuthority.decodeJSON(clientAuthorityJSON + Data(" {}".utf8))
fatalError("client authority accepted trailing JSON")
} catch { }
do { do {
_ = try AllocationPolicy( _ = try AllocationPolicy(
minimumKbps: 100, targetKbps: 50, maximumKbps: 25, tier: "standard", minimumKbps: 100, targetKbps: 50, maximumKbps: 25, tier: "standard",
@@ -272,6 +383,32 @@ fn main() {
+ &"g".repeat(43) + "1:016:" + &"n".repeat(16) + &"g".repeat(43) + "1:016:" + &"n".repeat(16)
+ "10:quic-tls1311:datagram-v17:encoded7:encoded6:server1:19:h264-opus"; + "10:quic-tls1311:datagram-v17:encoded7:encoded6:server1:19:h264-opus";
assert_eq!(request.device_admission_transcript(), transcript.into_bytes()); assert_eq!(request.device_admission_transcript(), transcript.into_bytes());
let proof_server_id = vec![1u8; 16];
let proof_principal_id = vec![2u8; 16];
let proof_device_id = vec![3u8; 16];
let proof_challenge = vec![4u8; 32];
let proof = device_registration_proof_transcript(
&proof_server_id, &proof_principal_id, &proof_device_id, &proof_challenge, 1,
).unwrap();
assert_eq!(proof.len(), 112);
assert_eq!(&proof[..24], b"versevdi-device-proof-v1");
assert_eq!(&proof[104..], &[0, 0, 0, 0, 0, 0, 0, 1]);
for (server_id, principal_id, device_id, challenge, expiry, field, code) in [
(vec![0; 15], proof_principal_id.clone(), proof_device_id.clone(), proof_challenge.clone(), 0, "server_id", "invalid_length"),
(vec![0; 17], proof_principal_id.clone(), proof_device_id.clone(), proof_challenge.clone(), 0, "server_id", "invalid_length"),
(proof_server_id.clone(), vec![0; 15], proof_device_id.clone(), proof_challenge.clone(), 0, "principal_id", "invalid_length"),
(proof_server_id.clone(), vec![0; 17], proof_device_id.clone(), proof_challenge.clone(), 0, "principal_id", "invalid_length"),
(proof_server_id.clone(), proof_principal_id.clone(), vec![0; 15], proof_challenge.clone(), 0, "device_id", "invalid_length"),
(proof_server_id.clone(), proof_principal_id.clone(), vec![0; 17], proof_challenge.clone(), 0, "device_id", "invalid_length"),
(proof_server_id.clone(), proof_principal_id.clone(), proof_device_id.clone(), vec![0; 31], 0, "challenge", "invalid_length"),
(proof_server_id.clone(), proof_principal_id.clone(), proof_device_id.clone(), vec![0; 33], 0, "challenge", "invalid_length"),
(proof_server_id.clone(), proof_principal_id.clone(), proof_device_id.clone(), proof_challenge.clone(), -1, "expiry_unix_milliseconds", "minimum"),
] {
assert_eq!(
device_registration_proof_transcript(&server_id, &principal_id, &device_id, &challenge, expiry),
Err(ValidationError::new(field, code)),
);
}
assert!(TunnelAdmissionRequest::new( assert!(TunnelAdmissionRequest::new(
"2".into(), "session".into(), "gateway".into(), "audience".into(), "2".into(), "session".into(), "gateway".into(), "audience".into(),
"g".repeat(43), 0, "n".repeat(16), "s".repeat(86), capabilities.clone(), "g".repeat(43), 0, "n".repeat(16), "s".repeat(86), capabilities.clone(),
@@ -284,6 +421,16 @@ fn main() {
"1".into(), "session".into(), "gateway".into(), "audience".into(), "1".into(), "session".into(), "gateway".into(), "audience".into(),
"g".repeat(43), 0, "short".into(), "s".repeat(86), capabilities.clone(), "g".repeat(43), 0, "short".into(), "s".repeat(86), capabilities.clone(),
).is_err()); ).is_err());
let client_authority = ClientSessionAuthority::new(
"1".into(), "session".into(), "gateway".into(), "audience".into(), 2,
"2099-01-01T00:00:00Z".into(), capabilities.clone(),
).unwrap();
assert_eq!(client_authority.sessionId(), "session");
assert_eq!(client_authority.capabilities(), &capabilities);
assert!(ClientSessionAuthority::new(
"1".into(), "session".into(), "gateway".into(), "audience".into(), 2,
"not-a-time".into(), capabilities.clone(),
).is_err());
assert!(intersect_capability_profiles(&[capabilities.clone(), capabilities.clone()]).is_ok()); assert!(intersect_capability_profiles(&[capabilities.clone(), capabilities.clone()]).is_ok());
let incompatible = CapabilityProfile::new( let incompatible = CapabilityProfile::new(
"quic-tls13".into(), "datagram-v1".into(), "encoded".into(), "quic-tls13".into(), "datagram-v1".into(), "encoded".into(),
@@ -377,6 +524,21 @@ fn main() {
) )
run(["rustc", str(rust), "-o", str(workspace / "rust-contracts")], ROOT) run(["rustc", str(rust), "-o", str(workspace / "rust-contracts")], ROOT)
run([str(workspace / "rust-contracts")], ROOT) run([str(workspace / "rust-contracts")], ROOT)
expected_protobuf_fields = [
("version", 1),
("session_id", 2),
("gateway_id", 3),
("audience", 4),
("reconnect_sequence", 5),
("expires_at", 6),
("capabilities", 7),
]
actual_protobuf_fields = protobuf_message_fields("ClientSessionAuthority")
if actual_protobuf_fields != expected_protobuf_fields:
raise RuntimeError(
f"ClientSessionAuthority protobuf fields = {actual_protobuf_fields}; "
f"want {expected_protobuf_fields}"
)
rust_unknown = workspace / "unknown.rs" rust_unknown = workspace / "unknown.rs"
shutil.copyfile(ROOT / "gen/rust/protocol.rs", rust_unknown) shutil.copyfile(ROOT / "gen/rust/protocol.rs", rust_unknown)
with rust_unknown.open("a", encoding="utf-8") as output: with rust_unknown.open("a", encoding="utf-8") as output:
+43 -1
View File
@@ -76,6 +76,14 @@ def main() -> int:
assert tunnel_credential["required"] == [ assert tunnel_credential["required"] == [
"client_device_id", "device_key_id", "certificate_chain_pem", "trust_bundle_pem", "expires_at" "client_device_id", "device_key_id", "certificate_chain_pem", "trust_bundle_pem", "expires_at"
] ]
client_authority_expiry = defs["ClientSessionAuthority"]["properties"]["expires_at"]
assert client_authority_expiry["format"] == "date-time"
client_authority_expiry_pattern = re.compile(client_authority_expiry.get("pattern", r"(?!)"))
assert client_authority_expiry_pattern.fullmatch("2099-01-01T00:00:00Z"), "client authority expiry must accept canonical UTC"
for noncanonical_expiry in ("2099-01-01T00:00:00+00:00", "2099-01-01T00:00:00.100Z"):
assert not client_authority_expiry_pattern.fullmatch(noncanonical_expiry), (
f"client authority expiry accepted noncanonical UTC {noncanonical_expiry}"
)
manifest = json.loads((ROOT / "fixtures/valid/manifest.json").read_text(encoding="utf-8")) manifest = json.loads((ROOT / "fixtures/valid/manifest.json").read_text(encoding="utf-8"))
assert set(manifest).issubset(set(defs["ConnectionManifest"]["properties"])) assert set(manifest).issubset(set(defs["ConnectionManifest"]["properties"]))
@@ -116,7 +124,9 @@ def main() -> int:
assert len(fields) == 5, line assert len(fields) == 5, line
assert fields[0] not in ids, fields[0] assert fields[0] not in ids, fields[0]
ids.add(fields[0]) ids.add(fields[0])
assert fields[4] == "valid" or fields[4].startswith("invalid:"), line assert fields[4] == "valid" or fields[4].startswith("invalid:") or (
fields[2] == "device_proof_transcript" and re.fullmatch(r"[0-9a-f]{224}", fields[4])
), line
fixture_manifest = json.loads((ROOT / "fixtures/manifest.json").read_text(encoding="utf-8")) fixture_manifest = json.loads((ROOT / "fixtures/manifest.json").read_text(encoding="utf-8"))
assert fixture_manifest["files"] == sorted( assert fixture_manifest["files"] == sorted(
@@ -145,6 +155,38 @@ def main() -> int:
assert "browserSession" not in tunnel_endpoint and "requestBody:" not in tunnel_endpoint assert "browserSession" not in tunnel_endpoint and "requestBody:" not in tunnel_endpoint
assert "$defs/NativeTunnelCredential" in tunnel_endpoint assert "$defs/NativeTunnelCredential" in tunnel_endpoint
assert "Cache-Control:" in tunnel_endpoint and "const: no-store" in tunnel_endpoint assert "Cache-Control:" in tunnel_endpoint and "const: no-store" in tunnel_endpoint
csrf_schemes = """ browserCsrfCookie:
type: apiKey
in: cookie
name: versevdi_csrf
description: Must be identical to X-CSRF-Token and is checked against Server session state.
browserCsrfHeader:
type: apiKey
in: header
name: X-CSRF-Token
description: Must be identical to the versevdi_csrf cookie and is checked against Server session state.
"""
assert csrf_schemes in openapi, "missing exact browser CSRF security schemes"
browser_requirement = """ security:
- browserSession: []
browserCsrfCookie: []
browserCsrfHeader: []
"""
for operation_id in (
"issueReauthenticationGrant", "logoutSession", "registerDevice", "proveDevice", "revokeDevice",
"requestBrokerSession", "allocateBrokerSession", "reconnectBrokerSession", "cancelBrokerSession",
):
operation = openapi.split(f" operationId: {operation_id}\n", 1)[1].split(" responses:\n", 1)[0]
assert browser_requirement.removeprefix(" ") in operation, f"{operation_id}: missing browser CSRF AND requirement"
for operation_id in ("logoutSession", "requestBrokerSession", "allocateBrokerSession", "reconnectBrokerSession", "cancelBrokerSession"):
operation = openapi.split(f" operationId: {operation_id}\n", 1)[1].split(" responses:\n", 1)[0]
assert " browserCsrfHeader: []\n - nativeBearer: []\n" in operation, f"{operation_id}: native bearer must remain a separate OR requirement"
for operation_id in ("loginBrowserSession", "rotateNativeCredential", "issueNativeTunnelCredential"):
operation = openapi.split(f" operationId: {operation_id}\n", 1)[1].split(" responses:\n", 1)[0]
assert "browserCsrf" not in operation, f"{operation_id}: excluded operation gained browser CSRF"
for operation_id in ("getAuthenticatedSession", "listResources", "getBrokerSession", "resumeUserEvents"):
operation = openapi.split(f" operationId: {operation_id}\n", 1)[1].split(" responses:\n", 1)[0]
assert "browserCsrf" not in operation, f"{operation_id}: safe GET gained browser CSRF"
assert defs["ManifestGateway"]["properties"]["public_identity"]["description"] == ( assert defs["ManifestGateway"]["properties"]["public_identity"]["description"] == (
"Exact TLS server name; distinct from dial addresses, gateway UUIDs, certificate fingerprints, and provider identities." "Exact TLS server name; distinct from dial addresses, gateway UUIDs, certificate fingerprints, and provider identities."
) )