Protocol: freeze device proof and browser CSRF contracts
Verify Protocol / module (push) Successful in 1m12s
Verify Protocol / verify (push) Successful in 22s

This commit is contained in:
sechmachine
2026-08-11 21:21:28 +07:00
parent afcd5d99db
commit b6a4f773e4
14 changed files with 323 additions and 5 deletions
+47
View File
@@ -187,6 +187,7 @@ def generate_go(defs: dict[str, dict[str, Any]], schema_hash: str, version: str,
"",
"import (",
"\"bytes\"",
"\"encoding/binary\"",
"\"encoding/base64\"",
"\"encoding/json\"",
"\"errors\"",
@@ -272,6 +273,22 @@ def generate_go(defs: dict[str, dict[str, Any]], schema_hash: str, version: str,
out.append("}")
out.append("")
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\")",
"",
"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([
"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> {",
" let mut selected = profiles.first().ok_or_else(|| ValidationError::new(\"capabilities\", \"no_overlap\"))?.clone();",
" 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([" }", "", " 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 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 {",
" 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)]",
+14
View File
@@ -58,6 +58,20 @@ func evaluate(version, kind, input string) string {
}
}
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":
if input == "2" || input == "1" || input == "0" {
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 {
let raw = match decode_hex(encoded) {
Some(raw) => raw,
@@ -333,7 +347,11 @@ fn main() {
for line in lines {
let fields: Vec<&str> = line.split('\t').collect();
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]);
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]? {
let characters = Array(encoded)
guard characters.count % 2 == 0 else { return nil }
@@ -229,7 +242,9 @@ struct ConformanceMain {
for line in lines {
let fields = line.split(separator: "\t", omittingEmptySubsequences: false).map(String.init)
precondition(fields.count == 5)
let actual = evaluate(fields[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])
results.append("\(fields[0])\t\(actual)")
}
+64
View File
@@ -59,6 +59,44 @@ let transcript = "versevdi/tunnel-admission/v17:session7:gateway8:audience43:" +
guard String(data: request.deviceAdmissionTranscript(), encoding: .utf8) == transcript else {
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(
transport: "quic-tls13", framing: "datagram-v1", media: "encoded",
audio: "encoded", sourceRateControl: "server", clientDecode: ["hevc-opus"]
@@ -272,6 +310,32 @@ fn main() {
+ &"g".repeat(43) + "1:016:" + &"n".repeat(16)
+ "10:quic-tls1311:datagram-v17:encoded7:encoded6:server1:19:h264-opus";
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(
"2".into(), "session".into(), "gateway".into(), "audience".into(),
"g".repeat(43), 0, "n".repeat(16), "s".repeat(86), capabilities.clone(),
+35 -1
View File
@@ -116,7 +116,9 @@ def main() -> int:
assert len(fields) == 5, line
assert fields[0] not in ids, 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"))
assert fixture_manifest["files"] == sorted(
@@ -145,6 +147,38 @@ def main() -> int:
assert "browserSession" not in tunnel_endpoint and "requestBody:" not in tunnel_endpoint
assert "$defs/NativeTunnelCredential" in tunnel_endpoint
assert "Cache-Control:" in tunnel_endpoint and "const: no-store" in tunnel_endpoint
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"] == (
"Exact TLS server name; distinct from dial addresses, gateway UUIDs, certificate fingerprints, and provider identities."
)