diff --git a/fixtures/conformance/device-proof-v1.tsv b/fixtures/conformance/device-proof-v1.tsv new file mode 100644 index 0000000..e49e238 --- /dev/null +++ b/fixtures/conformance/device-proof-v1.tsv @@ -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 diff --git a/fixtures/manifest.json b/fixtures/manifest.json index 2cc6dc9..56e430e 100644 --- a/fixtures/manifest.json +++ b/fixtures/manifest.json @@ -4,11 +4,12 @@ "fixtures/conformance/control-v1.tsv", "fixtures/conformance/datagram-v1.tsv", "fixtures/conformance/datagram-v2.tsv", + "fixtures/conformance/device-proof-v1.tsv", "fixtures/conformance/events-v1.tsv", "fixtures/conformance/gateway-clipboard-audit-v1.tsv", "fixtures/conformance/gateway-clipboard-v1.tsv", "fixtures/conformance/gateway-input-feedback-v1.tsv", "fixtures/conformance/tunnel-v1.tsv" ], - "corpus_sha256": "ed69937656f395b30f520861948f82ed3c0b21ea86e9b33c7949ed09942e701d" + "corpus_sha256": "6d2ce3a855b2fa45733a5f7b5b4c2e68448cceed5dfbca535ec81fe8cf230b30" } diff --git a/gen/go/protocol/protocol.go b/gen/go/protocol/protocol.go index 0aa384f..0b3a176 100644 --- a/gen/go/protocol/protocol.go +++ b/gen/go/protocol/protocol.go @@ -4,6 +4,7 @@ package protocol import ( "bytes" "encoding/base64" + "encoding/binary" "encoding/json" "errors" "fmt" @@ -5564,6 +5565,30 @@ func EncodeVersionNegotiation(value VersionNegotiation) ([]byte, error) { 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") func IntersectCapabilityProfiles(profiles ...CapabilityProfile) (CapabilityProfile, error) { diff --git a/gen/manifest.json b/gen/manifest.json index 6d04f60..5873866 100644 --- a/gen/manifest.json +++ b/gen/manifest.json @@ -12,7 +12,7 @@ "3" ] }, - "generator_sha256": "8a153cf1e99682d010ff91c754ef056c64aece8f8bbca0ca58f8eef2b9039119", + "generator_sha256": "00c1905fc611ca9e226cd90da761b48b8e203734b10542befea397a30082d360", "protocol_version": "1.0.0", "schema_sha256": "dea3dd210c53d5a2d37050dd6afd8b0ac5bb8edcb7ab25a02e4026489ce8a00f" } diff --git a/gen/rust/protocol.rs b/gen/rust/protocol.rs index d222c44..54dd370 100644 --- a/gen/rust/protocol.rs +++ b/gen/rust/protocol.rs @@ -1976,6 +1976,21 @@ impl VersionNegotiation { pub fn features(&self) -> &Vec { &self.features } } +pub fn device_registration_proof_transcript(server_id: &[u8], principal_id: &[u8], device_id: &[u8], challenge: &[u8], expiry_unix_milliseconds: i64) -> Result, 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 { let mut selected = profiles.first().ok_or_else(|| ValidationError::new("capabilities", "no_overlap"))?.clone(); selected.validate().map_err(|_| ValidationError::new("capabilities", "no_overlap"))?; diff --git a/gen/swift/Protocol.swift b/gen/swift/Protocol.swift index b06b4b1..4d4dc64 100644 --- a/gen/swift/Protocol.swift +++ b/gen/swift/Protocol.swift @@ -2583,6 +2583,21 @@ public struct VersionNegotiation: Codable, Equatable { 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 { 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)] diff --git a/openapi/control-v1.yaml b/openapi/control-v1.yaml index 452e340..75b6726 100644 --- a/openapi/control-v1.yaml +++ b/openapi/control-v1.yaml @@ -92,6 +92,8 @@ paths: operationId: issueReauthenticationGrant security: - browserSession: [] + browserCsrfCookie: [] + browserCsrfHeader: [] requestBody: required: true content: @@ -113,6 +115,8 @@ paths: operationId: logoutSession security: - browserSession: [] + browserCsrfCookie: [] + browserCsrfHeader: [] - nativeBearer: [] responses: '204': {description: Session revoked and browser cookies cleared.} @@ -123,6 +127,8 @@ paths: operationId: registerDevice security: - browserSession: [] + browserCsrfCookie: [] + browserCsrfHeader: [] requestBody: required: true content: @@ -144,6 +150,8 @@ paths: operationId: proveDevice security: - browserSession: [] + browserCsrfCookie: [] + browserCsrfHeader: [] parameters: - $ref: '#/components/parameters/DeviceID' requestBody: @@ -167,6 +175,8 @@ paths: operationId: revokeDevice security: - browserSession: [] + browserCsrfCookie: [] + browserCsrfHeader: [] parameters: - $ref: '#/components/parameters/DeviceID' responses: @@ -199,6 +209,8 @@ paths: description: Control wire version 2 endpoint. Legacy version-1 SessionRequest payloads containing client-supplied policy_snapshot are rejected. security: - browserSession: [] + browserCsrfCookie: [] + browserCsrfHeader: [] - nativeBearer: [] parameters: - $ref: '#/components/parameters/IdempotencyKey' @@ -249,6 +261,8 @@ paths: operationId: allocateBrokerSession security: - browserSession: [] + browserCsrfCookie: [] + browserCsrfHeader: [] - nativeBearer: [] parameters: - $ref: '#/components/parameters/SessionID' @@ -274,6 +288,8 @@ paths: operationId: reconnectBrokerSession security: - browserSession: [] + browserCsrfCookie: [] + browserCsrfHeader: [] - nativeBearer: [] parameters: - $ref: '#/components/parameters/SessionID' @@ -300,6 +316,8 @@ paths: operationId: cancelBrokerSession security: - browserSession: [] + browserCsrfCookie: [] + browserCsrfHeader: [] - nativeBearer: [] parameters: - $ref: '#/components/parameters/SessionID' @@ -353,6 +371,16 @@ components: type: apiKey in: cookie 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: type: http scheme: bearer diff --git a/tests/go/protocol_test.go b/tests/go/protocol_test.go index 950e2fe..5331a73 100644 --- a/tests/go/protocol_test.go +++ b/tests/go/protocol_test.go @@ -2,6 +2,7 @@ package protocol_test import ( "bytes" + "encoding/hex" "reflect" "strings" "testing" @@ -9,6 +10,45 @@ import ( 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) { 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)) diff --git a/tools/generate.py b/tools/generate.py index 5972a10..0a751f6 100644 --- a/tools/generate.py +++ b/tools/generate.py @@ -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, 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 {", " 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)]", diff --git a/tools/go-conformance/main.go b/tools/go-conformance/main.go index 435cae8..be14f64 100644 --- a/tools/go-conformance/main.go +++ b/tools/go-conformance/main.go @@ -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" diff --git a/tools/native_conformance.rs b/tools/native_conformance.rs index 976242d..74a30f3 100644 --- a/tools/native_conformance.rs +++ b/tools/native_conformance.rs @@ -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::().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)); } diff --git a/tools/native_conformance.swift b/tools/native_conformance.swift index c266278..c654eea 100644 --- a/tools/native_conformance.swift +++ b/tools/native_conformance.swift @@ -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)") } diff --git a/tools/test_generated_contracts.py b/tools/test_generated_contracts.py index b5c0b9b..92ff023 100644 --- a/tools/test_generated_contracts.py +++ b/tools/test_generated_contracts.py @@ -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(), diff --git a/tools/validate.py b/tools/validate.py index 1f241fd..bf71123 100644 --- a/tools/validate.py +++ b/tools/validate.py @@ -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." )