fix(protocol): harden gateway contract validation
Verify Protocol / verify (push) Canceled after 0s
Verify Protocol / module (push) Successful in 2m12s

This commit is contained in:
sechmachine
2026-07-29 21:16:53 +07:00
parent 0ea21cd3f2
commit ebfe07376d
18 changed files with 337 additions and 44 deletions
+28 -15
View File
@@ -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:
+44 -1
View File
@@ -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<u8> {",
" 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):
+12 -6
View File
@@ -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"))
+5 -1
View File
@@ -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" }
+8 -1
View File
@@ -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")
+100
View File
@@ -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()
+32
View File
@@ -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());
}
"""
)