diff --git a/Makefile b/Makefile index b352289..805f141 100644 --- a/Makefile +++ b/Makefile @@ -20,6 +20,7 @@ source-verify: $(PYTHON) -B tools/fixture_digest.py scope-verify: + $(PYTHON) -B tools/test_check_scope.py $(PYTHON) -B tools/check_scope.py go-test: diff --git a/fixtures/conformance/gateway-clipboard-v1.tsv b/fixtures/conformance/gateway-clipboard-v1.tsv index 225007c..38a3495 100644 --- a/fixtures/conformance/gateway-clipboard-v1.tsv +++ b/fixtures/conformance/gateway-clipboard-v1.tsv @@ -1,6 +1,12 @@ id version kind input expected valid-client-to-provider 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=abcdefghijklmnop valid valid-provider-to-client 1 gateway_clipboard direction=provider_to_client;text=host%20text;encoding=utf-8;loop_token=qrstuvwxyzABCDEF valid +valid-token-alphabet 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_ valid +valid-token-max 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_ valid invalid-direction 1 gateway_clipboard direction=bidirectional;text=hello;encoding=utf-8;loop_token=abcdefghijklmnop invalid:clipboard invalid-token 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=short invalid:clipboard +invalid-token-15 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=abcdefghijklmno invalid:clipboard +invalid-token-129 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_A invalid:clipboard +invalid-token-character 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=!!!!!!!!!!!!!!!! invalid:clipboard +invalid-token-trailing-bits 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=AAAAAAAAAAAAAAAAAB invalid:clipboard invalid-file 1 gateway_clipboard direction=client_to_provider;text=hello;encoding=utf-8;loop_token=abcdefghijklmnop;file=file.txt invalid:forbidden diff --git a/fixtures/manifest.json b/fixtures/manifest.json index e7228fb..67c6994 100644 --- a/fixtures/manifest.json +++ b/fixtures/manifest.json @@ -9,5 +9,5 @@ "fixtures/conformance/gateway-input-feedback-v1.tsv", "fixtures/conformance/tunnel-v1.tsv" ], - "corpus_sha256": "92c84440bbcc08703e5465584a400b0463fea77559d41262855e778e7c3284cf" + "corpus_sha256": "69d5b12a533ff0d9786784b99aecc8a74a7ec2c6855b75c52e46ecff5bd3e6c5" } diff --git a/frames/gateway-clipboard-v1.md b/frames/gateway-clipboard-v1.md index 251be5f..5da2912 100644 --- a/frames/gateway-clipboard-v1.md +++ b/frames/gateway-clipboard-v1.md @@ -10,8 +10,8 @@ flow. Its UTF-8 JSON payload is a `GatewayClipboardText` object: `direction` is exact: the client may send only `client_to_provider`, and the gateway may send only `provider_to_client`. The text contains no file name, URL, binary value, or client-folder field and is at most the Server-owned -`clipboard_policy.max_text_bytes` value. `loop_token` is a 16--128 ASCII -base64url-character token generated by the originating endpoint. An endpoint MUST retain +`clipboard_policy.max_text_bytes` value. `loop_token` is a 16--128 character +canonical unpadded ASCII base64url token generated by the originating endpoint. An endpoint MUST retain recent token/value pairs only for the bounded policy window and MUST suppress a matching reflected value; a mismatched, malformed, expired, or replayed token is rejected without clipboard mutation. @@ -21,8 +21,8 @@ direction, a rate above `max_updates_per_minute`, invalid UTF-8, an oversized payload, or an unknown field fails closed. Clipboard bytes are never emitted to provider-state, audit, telemetry, or error payloads. -For every accepted, loop-suppressed, or policy/rate/provider/malformed rejection, -the gateway sends an mTLS control-plane `GatewayClipboardAudit` record. It contains +For every successfully delivered, loop-suppressed, or policy/rate/provider/malformed +rejection, the gateway sends an mTLS control-plane `GatewayClipboardAudit` record. It contains only the session identifier, direction, bounded text-byte count, outcome, and a fixed reason code; it contains neither text nor loop token. The Server persists it against the broker session using the authenticated gateway identity. diff --git a/gen/go/protocol/protocol.go b/gen/go/protocol/protocol.go index b0c050d..67bd812 100644 --- a/gen/go/protocol/protocol.go +++ b/gen/go/protocol/protocol.go @@ -3,6 +3,7 @@ package protocol import ( "bytes" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -12,7 +13,7 @@ import ( "time" ) -const SchemaSHA256 = "e35414af52d7a097dea05567fdab11f842a42e529fb6cbedd281c48dbf930b17" +const SchemaSHA256 = "e98c75ef81bbeac6be2b8f11202c1ffecec0aa515b48576a26756290e99d5dd8" const ProtocolVersion = "1.0.0" const CurrentWireVersion = "1" const NMinus1WireVersion = "0" @@ -968,6 +969,9 @@ func (v ChannelFrame) Validate() error { if len(v.Payload) > 87384 { violations = append(violations, FieldViolation{Field: "payload", Code: "max_length"}) } + if len(v.Payload) > 65536 { + violations = append(violations, FieldViolation{Field: "payload", Code: "max_bytes"}) + } if v.FragmentIndex >= v.FragmentCount { violations = append(violations, FieldViolation{Field: "fragment_index", Code: "invalid_order"}) } @@ -1010,9 +1014,6 @@ func DecodeChannelFrame(data []byte) (ChannelFrame, error) { if raw, ok := fields["version"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { return value, ValidationError{Violations: []FieldViolation{{Field: "version", Code: "required"}}} } - if raw, ok := fields["payload"]; ok && len(raw) > 65536 { - return value, ValidationError{Violations: []FieldViolation{{Field: "payload", Code: "max_bytes"}}} - } decoder := json.NewDecoder(bytes.NewReader(data)) decoder.DisallowUnknownFields() if err := decoder.Decode(&value); err != nil { @@ -2144,6 +2145,9 @@ func (v GatewayClipboardText) Validate() error { if len(v.Text) > 65536 { violations = append(violations, FieldViolation{Field: "text", Code: "max_length"}) } + if len(v.Text) > 65536 { + violations = append(violations, FieldViolation{Field: "text", Code: "max_bytes"}) + } if v.Encoding == "" { violations = append(violations, FieldViolation{Field: "encoding", Code: "required"}) } @@ -2159,6 +2163,11 @@ func (v GatewayClipboardText) Validate() error { if len(v.LoopToken) > 128 { violations = append(violations, FieldViolation{Field: "loop_token", Code: "max_length"}) } + if v.LoopToken != "" { + if _, err := base64.RawURLEncoding.Strict().DecodeString(v.LoopToken); err != nil { + violations = append(violations, FieldViolation{Field: "loop_token", Code: "invalid_format"}) + } + } if len(violations) > 0 { return ValidationError{Violations: violations} } @@ -2186,9 +2195,6 @@ func DecodeGatewayClipboardText(data []byte) (GatewayClipboardText, error) { if raw, ok := fields["text"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { return value, ValidationError{Violations: []FieldViolation{{Field: "text", Code: "required"}}} } - if raw, ok := fields["text"]; ok && len(raw) > 65536 { - return value, ValidationError{Violations: []FieldViolation{{Field: "text", Code: "max_bytes"}}} - } decoder := json.NewDecoder(bytes.NewReader(data)) decoder.DisallowUnknownFields() if err := decoder.Decode(&value); err != nil { diff --git a/gen/manifest.json b/gen/manifest.json index aebc28f..0c4e17c 100644 --- a/gen/manifest.json +++ b/gen/manifest.json @@ -12,7 +12,7 @@ "2" ] }, - "generator_sha256": "e9c6ee1541585fcb00dcc5e94a5a6d93dbe3a719a5c545f31e5eda268f2638ab", + "generator_sha256": "922983e07a8ecc559771778fbf139155b14664742d9873be062880102777dccb", "protocol_version": "1.0.0", - "schema_sha256": "e35414af52d7a097dea05567fdab11f842a42e529fb6cbedd281c48dbf930b17" + "schema_sha256": "e98c75ef81bbeac6be2b8f11202c1ffecec0aa515b48576a26756290e99d5dd8" } diff --git a/gen/rust/protocol.rs b/gen/rust/protocol.rs index 094aae5..2a0e18f 100644 --- a/gen/rust/protocol.rs +++ b/gen/rust/protocol.rs @@ -1,6 +1,6 @@ // Code generated by tools/generate.py; DO NOT EDIT. #![allow(non_snake_case)] -pub const SCHEMA_SHA256: &str = "e35414af52d7a097dea05567fdab11f842a42e529fb6cbedd281c48dbf930b17"; +pub const SCHEMA_SHA256: &str = "e98c75ef81bbeac6be2b8f11202c1ffecec0aa515b48576a26756290e99d5dd8"; pub const CURRENT_WIRE_VERSION: &str = "1"; pub const N_MINUS_1_WIRE_VERSION: &str = "0"; pub const N_MINUS_2_WIRE_VERSION: &str = "-1"; @@ -10,6 +10,27 @@ pub type JsonObject = std::collections::BTreeMap; pub struct ValidationError { pub field: &'static str, pub code: &'static str } impl ValidationError { pub const fn new(field: &'static str, code: &'static str) -> Self { Self { field, code } } } +fn base64url_value(value: u8) -> Option { + match value { + b'A'..=b'Z' => Some(value - b'A'), + b'a'..=b'z' => Some(value - b'a' + 26), + b'0'..=b'9' => Some(value - b'0' + 52), + b'-' => Some(62), + b'_' => Some(63), + _ => None, + } +} +fn valid_base64_url(value: &str) -> bool { + let bytes = value.as_bytes(); + if bytes.is_empty() || bytes.iter().any(|byte| base64url_value(*byte).is_none()) { return false; } + match bytes.len() % 4 { + 0 => true, + 2 => base64url_value(*bytes.last().unwrap()).unwrap() & 0x0f == 0, + 3 => base64url_value(*bytes.last().unwrap()).unwrap() & 0x03 == 0, + _ => false, + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct AllocationPolicy { minimumKbps: i64, @@ -259,6 +280,7 @@ impl ChannelFrame { if self.fragmentCount > 16 { return Err(ValidationError::new("fragment_count", "maximum")); } if self.timestampMs < 0 { return Err(ValidationError::new("timestamp_ms", "minimum")); } if self.payload.len() > 87384 { return Err(ValidationError::new("payload", "max_length")); } + if self.payload.as_bytes().len() > 65536 { return Err(ValidationError::new("payload", "max_bytes")); } if self.fragmentIndex >= self.fragmentCount { return Err(ValidationError::new("fragment_index", "invalid_order")); } Ok(()) } @@ -692,10 +714,12 @@ impl GatewayClipboardText { pub fn validate(&self) -> Result<(), ValidationError> { if self.direction != "client_to_provider" && self.direction != "provider_to_client" { return Err(ValidationError::new("direction", "invalid_value")); } if self.text.len() > 65536 { return Err(ValidationError::new("text", "max_length")); } + if self.text.as_bytes().len() > 65536 { return Err(ValidationError::new("text", "max_bytes")); } if self.encoding != "utf-8" { return Err(ValidationError::new("encoding", "invalid_value")); } if self.loopToken.is_empty() { return Err(ValidationError::new("loop_token", "required")); } if !self.loopToken.is_empty() && self.loopToken.len() < 16 { return Err(ValidationError::new("loop_token", "min_length")); } if self.loopToken.len() > 128 { return Err(ValidationError::new("loop_token", "max_length")); } + if !valid_base64_url(self.loopToken.as_str()) { return Err(ValidationError::new("loop_token", "invalid_format")); } Ok(()) } pub fn direction(&self) -> &String { &self.direction } diff --git a/gen/swift/Protocol.swift b/gen/swift/Protocol.swift index c73d417..32c28eb 100644 --- a/gen/swift/Protocol.swift +++ b/gen/swift/Protocol.swift @@ -1,12 +1,21 @@ // Code generated by tools/generate.py; DO NOT EDIT. import Foundation public typealias JSONObject = [String: String] -public let schemaSHA256 = "e35414af52d7a097dea05567fdab11f842a42e529fb6cbedd281c48dbf930b17" +public let schemaSHA256 = "e98c75ef81bbeac6be2b8f11202c1ffecec0aa515b48576a26756290e99d5dd8" public let currentWireVersion = "1" public let nMinus1WireVersion = "0" public let nMinus2WireVersion = "-1" public struct ContractValidationError: Error, Equatable { public let field: String; public let code: String } private struct AnyCodingKey: CodingKey { let stringValue: String; let intValue: Int?; init?(stringValue: String) { self.stringValue = stringValue; self.intValue = nil }; init?(intValue: Int) { self.stringValue = String(intValue); self.intValue = intValue } } +private func validBase64URL(_ value: String) -> Bool { + guard !value.isEmpty, value.utf8.allSatisfy({ byte in + (byte >= 65 && byte <= 90) || (byte >= 97 && byte <= 122) || (byte >= 48 && byte <= 57) || byte == 45 || byte == 95 + }) else { return false } + let padding = String(repeating: "=", count: (4 - value.utf8.count % 4) % 4) + let standard = value.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + padding + guard let decoded = Data(base64Encoded: standard) else { return false } + return decoded.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "") == value +} public struct AllocationPolicy: Codable, Equatable { public let minimumKbps: Int64 @@ -343,6 +352,7 @@ public struct ChannelFrame: Codable, Equatable { if self.fragmentCount > 16 { throw ContractValidationError(field: "fragment_count", code: "maximum") } if self.timestampMs < 0 { throw ContractValidationError(field: "timestamp_ms", code: "minimum") } if self.payload.utf8.count > 87384 { throw ContractValidationError(field: "payload", code: "max_length") } + if self.payload.utf8.count > 65536 { throw ContractValidationError(field: "payload", code: "max_bytes") } if fragmentIndex >= fragmentCount { throw ContractValidationError(field: "fragment_index", code: "invalid_order") } } @@ -926,10 +936,12 @@ public struct GatewayClipboardText: Codable, Equatable { public func validate() throws { if !["client_to_provider", "provider_to_client"].contains(self.direction) { throw ContractValidationError(field: "direction", code: "invalid_value") } if self.text.utf8.count > 65536 { throw ContractValidationError(field: "text", code: "max_length") } + if self.text.utf8.count > 65536 { throw ContractValidationError(field: "text", code: "max_bytes") } if self.encoding != "utf-8" { throw ContractValidationError(field: "encoding", code: "invalid_value") } if self.loopToken.isEmpty { throw ContractValidationError(field: "loop_token", code: "required") } if !self.loopToken.isEmpty && self.loopToken.utf8.count < 16 { throw ContractValidationError(field: "loop_token", code: "min_length") } if self.loopToken.utf8.count > 128 { throw ContractValidationError(field: "loop_token", code: "max_length") } + if !validBase64URL(self.loopToken) { throw ContractValidationError(field: "loop_token", code: "invalid_format") } } public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) } diff --git a/openspec/changes/phase3c-apollo-input-feedback/specs/gateway-input-feedback/spec.md b/openspec/changes/phase3c-apollo-input-feedback/specs/gateway-input-feedback/spec.md index e27b228..a8f1b0e 100644 --- a/openspec/changes/phase3c-apollo-input-feedback/specs/gateway-input-feedback/spec.md +++ b/openspec/changes/phase3c-apollo-input-feedback/specs/gateway-input-feedback/spec.md @@ -33,7 +33,7 @@ an implementation-specific release-all provider command. packets reliably before starting provider disconnect. ### Requirement: Bounded provider feedback control envelope -The bidirectional reliable control flow SHALL define an ASCII `VGF1` envelope +The registered bidirectional reliable `control.ack.v1` flow SHALL define an ASCII `VGF1` envelope with a direction byte, type byte, big-endian payload length, and exact payload bytes. Only host termination, rumble, and HDR feedback SHALL be valid from the gateway to the client; only IDR and FEC/loss feedback SHALL be valid from the @@ -53,7 +53,8 @@ certificate, credential, or opaque provider packet. ### Requirement: Policy-bound text clipboard envelope The reliable `clipboard.text.v1` flow SHALL carry only a typed UTF-8 text -envelope with exact direction and a bounded loop token. The Server SHALL mint +envelope with exact direction and a 16--128 character canonical unpadded ASCII +base64url loop token. The Server SHALL mint the enabled directions, maximum text bytes, and maximum updates per minute in authenticated provider work. The gateway SHALL reject disabled direction, unknown fields, files, file URLs, client folders, binary data, malformed UTF-8, @@ -62,11 +63,15 @@ SHALL not put clipboard content, provider routes, or credentials in telemetry, audit, state, or errors. #### Scenario: Clipboard audit metadata -- **WHEN** the gateway accepts, suppresses, or rejects a clipboard update +- **WHEN** the gateway successfully delivers, suppresses, or rejects a clipboard update - **THEN** it sends an authenticated Server audit record with only direction, bounded byte count, outcome, and a fixed reason; it never includes text or the loop token. +#### Scenario: Clipboard delivery failure +- **WHEN** provider-to-client control delivery fails +- **THEN** the gateway does not report the update as forwarded. + #### Scenario: Reflected clipboard value - **WHEN** a client-originated text value returns from the provider with the matching retained token/value pair diff --git a/schemas/control-v1.schema.json b/schemas/control-v1.schema.json index 402bfc1..3160256 100644 --- a/schemas/control-v1.schema.json +++ b/schemas/control-v1.schema.json @@ -354,7 +354,7 @@ "direction": {"type": "string", "enum": ["client_to_provider", "provider_to_client"]}, "text": {"type": "string", "maxLength": 65536, "x-max-bytes": 65536}, "encoding": {"type": "string", "const": "utf-8"}, - "loop_token": {"type": "string", "minLength": 16, "maxLength": 128} + "loop_token": {"type": "string", "format": "base64url", "minLength": 16, "maxLength": 128} } }, "VersionNegotiation": { diff --git a/tests/go/protocol_test.go b/tests/go/protocol_test.go index 21018af..1e40215 100644 --- a/tests/go/protocol_test.go +++ b/tests/go/protocol_test.go @@ -141,3 +141,37 @@ func TestGatewayClipboardAuditIsMetadataOnlyAndStrict(t *testing.T) { } } } + +func TestGatewayClipboardTextMeasuresDecodedUTF8Bytes(t *testing.T) { + for name, text := range map[string]string{ + "ascii-boundary": strings.Repeat("a", 65536), + "utf8-boundary": strings.Repeat("é", 32768), + "escape-heavy": strings.Repeat(`"`, 32768), + } { + t.Run(name, func(t *testing.T) { + value := protocol.GatewayClipboardText{ + Direction: "client_to_provider", + Text: text, + Encoding: "utf-8", + LoopToken: "abcdefghijklmnop", + } + encoded, err := protocol.EncodeGatewayClipboardText(value) + if err != nil { + t.Fatalf("EncodeGatewayClipboardText() error = %v", err) + } + decoded, err := protocol.DecodeGatewayClipboardText(encoded) + if err != nil || decoded.Text != text { + t.Fatalf("DecodeGatewayClipboardText() = %d bytes, %v", len(decoded.Text), err) + } + }) + } + tooLarge := protocol.GatewayClipboardText{ + Direction: "client_to_provider", + Text: strings.Repeat("a", 65537), + Encoding: "utf-8", + LoopToken: "abcdefghijklmnop", + } + if _, err := protocol.EncodeGatewayClipboardText(tooLarge); err == nil { + t.Fatal("EncodeGatewayClipboardText() accepted 65,537 decoded UTF-8 bytes") + } +} diff --git a/tools/check_scope.py b/tools/check_scope.py index 02ea7e8..cc3a72f 100644 --- a/tools/check_scope.py +++ b/tools/check_scope.py @@ -30,6 +30,9 @@ SECRET_PATTERNS = ( re.compile(rb"\bgh[pousr]_[A-Za-z0-9]{20,}\b"), re.compile(rb"\bsk-[A-Za-z0-9]{20,}\b"), ) +ALLOWED_SECRET_PROPERTIES = { + ("ProviderSessionWork", "client_private_key_pem"), +} def fail(message: str) -> None: @@ -53,19 +56,36 @@ def check_generated_provenance() -> None: def check_manifest_schema() -> None: schema = json.loads((ROOT / "schemas/control-v1.schema.json").read_text(encoding="utf-8")) definitions = schema.get("$defs", {}) - for name in ("ConnectionManifest", "ManifestGateway", "ManifestTunnel", "ManifestProfile", "ManifestBounds", "GrantReference"): - properties = definitions.get(name, {}).get("properties", {}) - forbidden = sorted(FORBIDDEN_WIRE_FIELDS.intersection(properties)) - if forbidden: - fail(f"{name} exposes forbidden wire fields: {forbidden}") + for name, definition in definitions.items(): + for field in definition.get("properties", {}): + if any(forbidden in field.lower() for forbidden in FORBIDDEN_WIRE_FIELDS): + if (name, field) not in ALLOWED_SECRET_PROPERTIES: + fail(f"{name} exposes forbidden wire field {field}") + + +def check_proto_boundaries(path: pathlib.Path) -> None: + message = "" + depth = 0 + for line in path.read_text(encoding="utf-8").splitlines(): + match = re.match(r"\s*message\s+([A-Za-z0-9_]+)\s*\{", line) + if match and depth == 0: + message = match.group(1) + if any(field in line.lower() for field in FORBIDDEN_WIRE_FIELDS): + allowed = ( + message == "ProviderSessionWork" + and re.fullmatch(r"\s*string\s+client_private_key_pem\s*=\s*[0-9]+;\s*", line) + ) + if not allowed: + fail(f"{path.relative_to(ROOT)} exposes forbidden wire field in {message or 'file scope'}") + depth += line.count("{") - line.count("}") + if depth == 0: + message = "" def check_text_boundaries() -> None: paths = [ ROOT / "openapi/control-v1.yaml", - ROOT / "schemas/control-v1.schema.json", ROOT / "proto/versevdi/control/v1/control.proto", - ROOT / "proto/versevdi/tunnel/v1/tunnel.proto", ROOT / "frames/datagram-v1.md", ROOT / "frames/registry.json", ROOT / "registries/features.json", @@ -77,14 +97,7 @@ def check_text_boundaries() -> None: if field in text: fail(f"{path.relative_to(ROOT)} contains forbidden wire field {field}") - generated_paths = list((ROOT / "gen").rglob("*")) - for path in generated_paths: - if not path.is_file() or path.name == "manifest.json" or path.suffix in {".pb", ".binpb"}: - continue - text = path.read_text(encoding="utf-8").lower() - for field in FORBIDDEN_WIRE_FIELDS: - if field in text: - fail(f"generated output {path.relative_to(ROOT)} contains forbidden wire field {field}") + check_proto_boundaries(ROOT / "proto/versevdi/tunnel/v1/tunnel.proto") def check_secret_canaries() -> None: diff --git a/tools/generate.py b/tools/generate.py index 60e6605..4a99f62 100644 --- a/tools/generate.py +++ b/tools/generate.py @@ -125,6 +125,8 @@ def go_validation(definition: dict[str, Any]) -> list[str]: lines.append(f"\tif len(v.{field}) < {prop['minLength']} && v.{field} != \"\" {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"min_length\"}}) }}") if "maxLength" in prop: lines.append(f"\tif len(v.{field}) > {prop['maxLength']} {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"max_length\"}}) }}") + if "x-max-bytes" in prop: + lines.append(f"\tif len(v.{field}) > {prop['x-max-bytes']} {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"max_bytes\"}}) }}") if "const" in prop: lines.append(f"\tif v.{field} != \"{prop['const']}\" && v.{field} != \"\" {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"invalid_value\"}}) }}") if "enum" in prop: @@ -135,6 +137,8 @@ def go_validation(definition: dict[str, Any]) -> list[str]: '\tif v.%s != "" { if parsed, err := time.Parse(time.RFC3339Nano, v.%s); err != nil || parsed.UTC().Format(time.RFC3339Nano) != v.%s { violations = append(violations, FieldViolation{Field: "%s", Code: "invalid_time"}) } }' % (field, field, field, prop_name) ) + if prop.get("format") == "base64url": + lines.append(f"\tif v.{field} != \"\" {{ if _, err := base64.RawURLEncoding.Strict().DecodeString(v.{field}); err != nil {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"invalid_format\"}}) }} }}") if prop.get("type") == "integer": if "minimum" in prop: lines.append(f"\tif v.{field} != 0 && v.{field} < {prop['minimum']} {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"minimum\"}}) }}") @@ -167,6 +171,7 @@ def generate_go(defs: dict[str, dict[str, Any]], schema_hash: str, version: str, "", "import (", "\"bytes\"", + "\"encoding/base64\"", "\"encoding/json\"", "\"errors\"", "\"fmt\"", @@ -223,7 +228,7 @@ def generate_go(defs: dict[str, dict[str, Any]], schema_hash: str, version: str, % (prop_name, prop_name) ) for prop_name, prop in defs[name].get("properties", {}).items(): - if "x-max-bytes" in prop: + if "x-max-bytes" in prop and prop.get("type") != "string": out.append( '\tif raw, ok := fields["%s"]; ok && len(raw) > %d { return value, ValidationError{Violations: []FieldViolation{{Field: "%s", Code: "max_bytes"}}} }' % (prop_name, prop["x-max-bytes"], prop_name) @@ -322,11 +327,15 @@ def rust_validation(definition: dict[str, Any]) -> list[str]: lines.append(f" {prefix}if !{value}.is_empty() && {value}.len() < {prop['minLength']} {{ return Err(ValidationError::new(\"{prop_name}\", \"min_length\")); }}") if "maxLength" in prop: lines.append(f" {prefix}if {value}.len() > {prop['maxLength']} {{ return Err(ValidationError::new(\"{prop_name}\", \"max_length\")); }}") + if "x-max-bytes" in prop: + lines.append(f" {prefix}if {value}.as_bytes().len() > {prop['x-max-bytes']} {{ return Err(ValidationError::new(\"{prop_name}\", \"max_bytes\")); }}") if "const" in prop: lines.append(f" {prefix}if {value} != \"{prop['const']}\" {{ return Err(ValidationError::new(\"{prop_name}\", \"invalid_value\")); }}") if "enum" in prop: allowed = " && ".join(f'{value} != \"{item}\"' for item in prop["enum"]) lines.append(f" {prefix}if {allowed} {{ return Err(ValidationError::new(\"{prop_name}\", \"invalid_value\")); }}") + if prop.get("format") == "base64url": + lines.append(f" {prefix}if !valid_base64_url({value}.as_str()) {{ return Err(ValidationError::new(\"{prop_name}\", \"invalid_format\")); }}") if prop.get("type") == "integer": if "minimum" in prop: lines.append(f" {prefix}if {value} < {prop['minimum']} {{ return Err(ValidationError::new(\"{prop_name}\", \"minimum\")); }}") @@ -369,6 +378,27 @@ def generate_rust(defs: dict[str, dict[str, Any]], schema_hash: str, compatibili "pub struct ValidationError { pub field: &'static str, pub code: &'static str }", "impl ValidationError { pub const fn new(field: &'static str, code: &'static str) -> Self { Self { field, code } } }", "", + "fn base64url_value(value: u8) -> Option {", + " match value {", + " b'A'..=b'Z' => Some(value - b'A'),", + " b'a'..=b'z' => Some(value - b'a' + 26),", + " b'0'..=b'9' => Some(value - b'0' + 52),", + " b'-' => Some(62),", + " b'_' => Some(63),", + " _ => None,", + " }", + "}", + "fn valid_base64_url(value: &str) -> bool {", + " let bytes = value.as_bytes();", + " if bytes.is_empty() || bytes.iter().any(|byte| base64url_value(*byte).is_none()) { return false; }", + " match bytes.len() % 4 {", + " 0 => true,", + " 2 => base64url_value(*bytes.last().unwrap()).unwrap() & 0x0f == 0,", + " 3 => base64url_value(*bytes.last().unwrap()).unwrap() & 0x03 == 0,", + " _ => false,", + " }", + "}", + "", ] for name in sorted(defs): definition = defs[name] @@ -451,6 +481,8 @@ def swift_validation(definition: dict[str, Any]) -> list[str]: lines.append(f" {prefix}if !{value}.isEmpty && {value}.utf8.count < {prop['minLength']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"min_length\") }}") if "maxLength" in prop: lines.append(f" {prefix}if {value}.utf8.count > {prop['maxLength']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"max_length\") }}") + if "x-max-bytes" in prop: + lines.append(f" {prefix}if {value}.utf8.count > {prop['x-max-bytes']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"max_bytes\") }}") if "const" in prop: lines.append(f" {prefix}if {value} != \"{prop['const']}\" {{ throw ContractValidationError(field: \"{prop_name}\", code: \"invalid_value\") }}") if "enum" in prop: @@ -458,6 +490,8 @@ def swift_validation(definition: dict[str, Any]) -> list[str]: lines.append(f" {prefix}if ![{allowed}].contains({value}) {{ throw ContractValidationError(field: \"{prop_name}\", code: \"invalid_value\") }}") if prop.get("format") == "date-time": lines.append(f" {prefix}if ISO8601DateFormatter().date(from: {value}) == nil {{ throw ContractValidationError(field: \"{prop_name}\", code: \"invalid_time\") }}") + if prop.get("format") == "base64url": + lines.append(f" {prefix}if !validBase64URL({value}) {{ throw ContractValidationError(field: \"{prop_name}\", code: \"invalid_format\") }}") if prop.get("type") == "integer": if "minimum" in prop: lines.append(f" {prefix}if {value} < {prop['minimum']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"minimum\") }}") @@ -497,6 +531,15 @@ def generate_swift(defs: dict[str, dict[str, Any]], schema_hash: str, compatibil f'public let nMinus2WireVersion = "{compatibility["n_minus_2"]}"', "public struct ContractValidationError: Error, Equatable { public let field: String; public let code: String }", "private struct AnyCodingKey: CodingKey { let stringValue: String; let intValue: Int?; init?(stringValue: String) { self.stringValue = stringValue; self.intValue = nil }; init?(intValue: Int) { self.stringValue = String(intValue); self.intValue = intValue } }", + "private func validBase64URL(_ value: String) -> Bool {", + " guard !value.isEmpty, value.utf8.allSatisfy({ byte in", + " (byte >= 65 && byte <= 90) || (byte >= 97 && byte <= 122) || (byte >= 48 && byte <= 57) || byte == 45 || byte == 95", + " }) else { return false }", + " let padding = String(repeating: \"=\", count: (4 - value.utf8.count % 4) % 4)", + " let standard = value.replacingOccurrences(of: \"-\", with: \"+\").replacingOccurrences(of: \"_\", with: \"/\") + padding", + " guard let decoded = Data(base64Encoded: standard) else { return false }", + " return decoded.base64EncodedString().replacingOccurrences(of: \"+\", with: \"-\").replacingOccurrences(of: \"/\", with: \"_\").replacingOccurrences(of: \"=\", with: \"\") == value", + "}", "", ] for name in sorted(defs): diff --git a/tools/native_conformance.rs b/tools/native_conformance.rs index b8712cf..8bb3aa8 100644 --- a/tools/native_conformance.rs +++ b/tools/native_conformance.rs @@ -56,12 +56,18 @@ fn evaluate(kind: &str, input: &str) -> &'static str { "gateway_input" => classify_gateway_input(values.get("hex").map(String::as_str).unwrap_or_default()), "gateway_feedback" => classify_gateway_feedback(values.get("hex").map(String::as_str).unwrap_or_default()), "gateway_clipboard" if values.contains_key("file") => "invalid:forbidden", - "gateway_clipboard" - if matches!(values.get("direction").map(String::as_str), Some("client_to_provider") | Some("provider_to_client")) - && values.get("encoding").map(String::as_str) == Some("utf-8") - && values.get("loop_token").map_or(false, |value| (16..=128).contains(&value.len())) - && values.get("text").map_or(false, |value| value.len() <= 65536) => "valid", - "gateway_clipboard" => "invalid:clipboard", + "gateway_clipboard" => match ( + values.get("direction"), + values.get("text"), + values.get("encoding"), + values.get("loop_token"), + ) { + (Some(direction), Some(text), Some(encoding), Some(token)) + if GatewayClipboardText::new( + direction.clone(), text.clone(), encoding.clone(), token.clone(), + ).is_ok() => "valid", + _ => "invalid:clipboard", + }, "gateway_clipboard_audit" if values.contains_key("text") => "invalid:forbidden", "gateway_clipboard_audit" if matches!(values.get("direction").map(String::as_str), Some("client_to_provider") | Some("provider_to_client")) diff --git a/tools/native_conformance.swift b/tools/native_conformance.swift index 6670a85..f708801 100644 --- a/tools/native_conformance.swift +++ b/tools/native_conformance.swift @@ -34,7 +34,11 @@ func evaluate(_ kind: String, _ input: String) -> String { case "gateway_feedback": return classifyGatewayFeedback(values["hex"] ?? "") case "gateway_clipboard": if values["file"] != nil { return "invalid:forbidden" } - guard ["client_to_provider", "provider_to_client"].contains(values["direction"] ?? ""), values["encoding"] == "utf-8", let token = values["loop_token"], (16...128).contains(token.utf8.count), let text = values["text"], text.utf8.count <= 65536 else { return "invalid:clipboard" } + guard let direction = values["direction"], let text = values["text"], + let encoding = values["encoding"], let token = values["loop_token"], + (try? GatewayClipboardText( + direction: direction, text: text, encoding: encoding, loopToken: token + )) != nil else { return "invalid:clipboard" } return "valid" case "gateway_clipboard_audit": if values["text"] != nil { return "invalid:forbidden" } diff --git a/tools/run_native_conformance.py b/tools/run_native_conformance.py index 83a4760..781aec7 100644 --- a/tools/run_native_conformance.py +++ b/tools/run_native_conformance.py @@ -20,7 +20,14 @@ def main() -> int: temp = pathlib.Path(directory) rust_bin = temp / "rust-conformance" swift_bin = temp / "swift-conformance" - run(["rustc", "tools/native_conformance.rs", "-O", "-o", str(rust_bin)]) + rust_source = temp / "main.rs" + rust_source.write_text( + (ROOT / "gen/rust/protocol.rs").read_text(encoding="utf-8") + + "\n" + + (ROOT / "tools/native_conformance.rs").read_text(encoding="utf-8"), + encoding="utf-8", + ) + run(["rustc", str(rust_source), "-O", "-o", str(rust_bin)]) run([str(rust_bin)]) main_source = temp / "main.swift" main_source.write_text((ROOT / "tools/native_conformance.swift").read_text(encoding="utf-8"), encoding="utf-8") diff --git a/tools/test_check_scope.py b/tools/test_check_scope.py new file mode 100644 index 0000000..33dd214 --- /dev/null +++ b/tools/test_check_scope.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Focused contract-boundary regressions for check_scope.py.""" + +from __future__ import annotations + +import json +import pathlib +import tempfile + +import check_scope + + +TEXT_PATHS = ( + "openapi/control-v1.yaml", + "proto/versevdi/control/v1/control.proto", + "proto/versevdi/tunnel/v1/tunnel.proto", + "frames/datagram-v1.md", + "frames/registry.json", + "registries/features.json", + "registries/datagrams.json", +) +PROVIDER_WORK_PROTO = """ +message ProviderSessionWork { + string client_private_key_pem = 15; +} +""" + + +def schema_with_private_key(owner: str) -> dict[str, object]: + definitions = { + name: {"type": "object", "properties": {}} + for name in ( + "ConnectionManifest", + "ManifestGateway", + "ManifestTunnel", + "ManifestProfile", + "ManifestBounds", + "GrantReference", + "ProviderSessionWork", + ) + } + definitions[owner]["properties"] = { + "client_private_key_pem": {"type": "string"}, + } + return {"$defs": definitions} + + +def run_scope(schema: dict[str, object], overrides: dict[str, str] | None = None) -> None: + with tempfile.TemporaryDirectory() as directory: + root = pathlib.Path(directory) + schema_path = root / "schemas/control-v1.schema.json" + schema_path.parent.mkdir(parents=True) + schema_path.write_text(json.dumps(schema), encoding="utf-8") + for relative in TEXT_PATHS: + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + default = PROVIDER_WORK_PROTO if relative == "proto/versevdi/tunnel/v1/tunnel.proto" else "" + path.write_text((overrides or {}).get(relative, default), encoding="utf-8") + (root / "gen").mkdir() + + original_root = check_scope.ROOT + check_scope.ROOT = root + try: + check_scope.check_manifest_schema() + check_scope.check_text_boundaries() + finally: + check_scope.ROOT = original_root + + +def expect_rejected(schema: dict[str, object], overrides: dict[str, str] | None = None) -> None: + try: + run_scope(schema, overrides) + except ValueError: + return + raise AssertionError("client-visible private-key material was accepted") + + +def main() -> None: + run_scope(schema_with_private_key("ProviderSessionWork")) + expect_rejected(schema_with_private_key("ConnectionManifest")) + expect_rejected(schema_with_private_key("ManifestProfile")) + expect_rejected( + schema_with_private_key("ProviderSessionWork"), + { + "proto/versevdi/tunnel/v1/tunnel.proto": """ +message ConnectionManifest { + string client_private_key_pem = 1; +} +""" + }, + ) + expect_rejected( + schema_with_private_key("ProviderSessionWork"), + {"frames/datagram-v1.md": "client_private_key_pem"}, + ) + print("Protocol contract-aware scope regression passed") + + +if __name__ == "__main__": + main() diff --git a/tools/test_generated_contracts.py b/tools/test_generated_contracts.py index 7b6c012..bde3840 100644 --- a/tools/test_generated_contracts.py +++ b/tools/test_generated_contracts.py @@ -85,6 +85,25 @@ do { ) fatalError("invalid allocation bounds were accepted") } catch { } +for text in [ + String(repeating: "a", count: 65536), + String(repeating: "é", count: 32768), + String(repeating: "\\\"", count: 32768), +] { + let clipboard = try GatewayClipboardText( + direction: "client_to_provider", text: text, encoding: "utf-8", + loopToken: "abcdefghijklmnop" + ) + let decoded = try GatewayClipboardText.decodeJSON(clipboard.encodeJSON()) + guard decoded.text == text else { fatalError("clipboard text changed during round-trip") } +} +do { + _ = try GatewayClipboardText( + direction: "client_to_provider", text: String(repeating: "a", count: 65537), + encoding: "utf-8", loopToken: "abcdefghijklmnop" + ) + fatalError("oversized clipboard text was accepted") +} catch { } """, encoding="utf-8", ) @@ -130,6 +149,19 @@ fn main() { assert!(AllocationPolicy::new( 100, 50, 25, "standard".into(), "audience".into(), "verse".into(), 1, 60, 300, ).is_err()); + for text in [ + "a".repeat(65536), + "é".repeat(32768), + "\\\"".repeat(32768), + ] { + assert!(GatewayClipboardText::new( + "client_to_provider".into(), text, "utf-8".into(), "abcdefghijklmnop".into(), + ).is_ok()); + } + assert!(GatewayClipboardText::new( + "client_to_provider".into(), "a".repeat(65537), "utf-8".into(), + "abcdefghijklmnop".into(), + ).is_err()); } """ )