Protocol: freeze device proof and browser CSRF contracts
This commit is contained in:
@@ -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)]",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
|
||||
@@ -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
@@ -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."
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user