diff --git a/Source/ProofOfConcept/Models/TelemetryConfigRequest.cs b/Source/ProofOfConcept/Models/TelemetryConfigRequest.cs new file mode 100644 index 0000000..406f7fd --- /dev/null +++ b/Source/ProofOfConcept/Models/TelemetryConfigRequest.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; + +namespace ProofOfConcept.Models; + +public sealed class TelemetryConfigRequest +{ + [JsonPropertyName("vins")] + public List Vins { get; set; } = new(); + + [JsonPropertyName("config")] + public TelemetryConfig Config { get; set; } = new(); +} + +public sealed class TelemetryConfig +{ + [JsonPropertyName("hostname")] + public string Hostname { get; set; } = string.Empty; + + [JsonPropertyName("port")] + public int Port { get; set; } + + [JsonPropertyName("ca")] + public string CertificateAuthority { get; set; } = string.Empty; + + [JsonPropertyName("fields")] + public Dictionary Fields { get; set; } = new(); +} + +public sealed class TelemetryFieldConfig +{ + [JsonPropertyName("interval_seconds")] + public int IntervalSeconds { get; set; } +} \ No newline at end of file diff --git a/Source/ProofOfConcept/Models/VehiclesResponse.cs b/Source/ProofOfConcept/Models/VehiclesResponse.cs new file mode 100644 index 0000000..70a7aca --- /dev/null +++ b/Source/ProofOfConcept/Models/VehiclesResponse.cs @@ -0,0 +1,29 @@ +namespace ProofOfConcept.Models; + +using System.Text.Json.Serialization; + +public sealed class VehiclesEnvelope +{ + [JsonPropertyName("response")] + public List Response { get; set; } = new(); + + [JsonPropertyName("count")] + public int Count { get; set; } +} + +public sealed class VehicleSummary +{ + // Tesla fields commonly present on /api/1/vehicles + [JsonPropertyName("id")] public long Id { get; set; } + [JsonPropertyName("vehicle_id")] public long VehicleId { get; set; } + [JsonPropertyName("vin")] public string? Vin { get; set; } + [JsonPropertyName("display_name")] public string? DisplayName { get; set; } + [JsonPropertyName("state")] public string? State { get; set; } + + // Extra fields sometimes included; safe to keep nullable + [JsonPropertyName("in_service")] public bool? InService { get; set; } + [JsonPropertyName("calendar_enabled")] public bool? CalendarEnabled { get; set; } + [JsonPropertyName("id_s")] public string? IdS { get; set; } + [JsonPropertyName("option_codes")] public string? OptionCodes { get; set; } + [JsonPropertyName("color")] public string? Color { get; set; } +} \ No newline at end of file diff --git a/Source/ProofOfConcept/Program.cs b/Source/ProofOfConcept/Program.cs index e8ded66..4f12062 100644 --- a/Source/ProofOfConcept/Program.cs +++ b/Source/ProofOfConcept/Program.cs @@ -226,6 +226,52 @@ if (app.Environment.IsDevelopment()) return json; }); + app.MapGet("/Tesla", async ([FromServices] ILogger logger, [FromServices] IHttpContextAccessor httpContextAccessor, [FromServices] IHttpClientFactory httpClientFactory) => + { + HttpContext? context = httpContextAccessor.HttpContext; + + if (context is null) + return Results.BadRequest(); + + string? access_token = await context.GetTokenAsync("access_token"); + string? refresh_token = await context.GetTokenAsync("refresh_token"); + logger.LogCritical("User has access_token: {access_token} and refresh_token: {refresh_token}", access_token, refresh_token); + + if (String.IsNullOrEmpty(access_token)) + return Results.RedirectToRoute("/Authorize"); + + HttpClient client = httpClientFactory.CreateClient(); + client.BaseAddress = new Uri("tesla_command_proxy"); + client.DefaultRequestHeaders.Add("Authorization", $"Bearer {access_token}"); + client.DefaultRequestHeaders.Add("Content-Type", "application/json"); + + //Get cars + VehiclesEnvelope? vehiclesEnvelope = await client.GetFromJsonAsync("/api/1/vehicles"); + string[] vins = vehiclesEnvelope?.Response.Select(x => x.Vin ?? "").Where(v => !String.IsNullOrWhiteSpace(v)).ToArray() ?? Array.Empty(); + logger.LogCritical("User has access to {count} cars: {vins}", vins.Length, String.Join(", ", vins)); + + TelemetryConfigRequest configRequest = new TelemetryConfigRequest() + { + Vins = new List(vins), + Config = new TelemetryConfig() + { + Hostname = "tesla-connector.automatic-parking.app", + Port = 443, + CertificateAuthority = "-----BEGIN CERTIFICATE-----\\nMIIEVzCCAj+gAwIBAgIRAIOPbGPOsTmMYgZigxXJ/d4wDQYJKoZIhvcNAQELBQAw\\nTzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh\\ncmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjQwMzEzMDAwMDAw\\nWhcNMjcwMzEyMjM1OTU5WjAyMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg\\nRW5jcnlwdDELMAkGA1UEAxMCRTUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQNCzqK\\na2GOtu/cX1jnxkJFVKtj9mZhSAouWXW0gQI3ULc/FnncmOyhKJdyIBwsz9V8UiBO\\nVHhbhBRrwJCuhezAUUE8Wod/Bk3U/mDR+mwt4X2VEIiiCFQPmRpM5uoKrNijgfgw\\ngfUwDgYDVR0PAQH/BAQDAgGGMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcD\\nATASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBSfK1/PPCFPnQS37SssxMZw\\ni9LXDTAfBgNVHSMEGDAWgBR5tFnme7bl5AFzgAiIyBpY9umbbjAyBggrBgEFBQcB\\nAQQmMCQwIgYIKwYBBQUHMAKGFmh0dHA6Ly94MS5pLmxlbmNyLm9yZy8wEwYDVR0g\\nBAwwCjAIBgZngQwBAgEwJwYDVR0fBCAwHjAcoBqgGIYWaHR0cDovL3gxLmMubGVu\\nY3Iub3JnLzANBgkqhkiG9w0BAQsFAAOCAgEAH3KdNEVCQdqk0LKyuNImTKdRJY1C\\n2uw2SJajuhqkyGPY8C+zzsufZ+mgnhnq1A2KVQOSykOEnUbx1cy637rBAihx97r+\\nbcwbZM6sTDIaEriR/PLk6LKs9Be0uoVxgOKDcpG9svD33J+G9Lcfv1K9luDmSTgG\\n6XNFIN5vfI5gs/lMPyojEMdIzK9blcl2/1vKxO8WGCcjvsQ1nJ/Pwt8LQZBfOFyV\\nXP8ubAp/au3dc4EKWG9MO5zcx1qT9+NXRGdVWxGvmBFRAajciMfXME1ZuGmk3/GO\\nkoAM7ZkjZmleyokP1LGzmfJcUd9s7eeu1/9/eg5XlXd/55GtYjAM+C4DG5i7eaNq\\ncm2F+yxYIPt6cbbtYVNJCGfHWqHEQ4FYStUyFnv8sjyqU8ypgZaNJ9aVcWSICLOI\\nE1/Qv/7oKsnZCWJ926wU6RqG1OYPGOi1zuABhLw61cuPVDT28nQS/e6z95cJXq0e\\nK1BcaJ6fJZsmbjRgD5p3mvEf5vdQM7MCEvU0tHbsx2I5mHHJoABHb8KVBgWp/lcX\\nGWiWaeOyB7RP+OfDtvi2OsapxXiV7vNVs7fMlrRjY1joKaqmmycnBvAq14AEbtyL\\nsVfOS66B8apkeFX2NY4XPEYV4ZSCe8VHPrdrERk2wILG3T/EGmSIkCYVUMSnjmJd\\nVQD9F6Na/+zmXCc=\\n-----END CERTIFICATE-----\\n---\\nServer certificate\\nsubject=CN=tesla-connector.automatic-parking.app\\nissuer=C=US, O=Let's Encrypt, CN=E5\\n---\\nAcceptable client certificate CA names\\nCN=Tesla Issuing CA, O=Tesla Motors, L=Palo Alto, ST=California, C=US\\nCN=Tesla Motors GF Austin Product Issuing CA, OU=Motors, OU=PKI, O=Tesla Inc., C=US\\nCN=Tesla Motors GF Berlin Product Issuing CA, OU=Motors, OU=PKI, O=Tesla Inc., C=US\\nCN=Tesla Motors GF0 Product Issuing CA, OU=Motors, OU=PKI, O=Tesla Inc., C=US\\nCN=Tesla Motors GF3 Product Issuing CA, OU=Motors, OU=PKI, O=Tesla Inc., C=US\\nCN=Tesla Motors GF3 Product RSA Issuing CA, OU=Motors, OU=PKI, O=Tesla Inc., C=US\\nCN=Tesla Motors Product Issuing CA, OU=Motors, OU=PKI, O=Tesla Inc., C=US\\nCN=Tesla Motors Product RSA Issuing CA, OU=Motors, OU=PKI, O=Tesla Inc., C=US\\nCN=Tesla Motors Products CA\\nCN=Tesla Motors Root CA\\nCN=Tesla Policy CA, O=Tesla Motors, L=Palo Alto, ST=California, C=US\\nCN=Tesla Product RSA Root CA, OU=PKI, O=Tesla, C=US\\nCN=Tesla Product Root CA, OU=PKI, O=Tesla, C=US\\nCN=Tesla Root CA, O=Tesla Motors, L=Palo Alto, ST=California, C=US\\nRequested Signature Algorithms: RSA-PSS+SHA256:ECDSA+SHA256:ed25519:RSA-PSS+SHA384:RSA-PSS+SHA512:RSA+SHA256:RSA+SHA384:RSA+SHA512:ECDSA+SHA384:ECDSA+SHA512:RSA+SHA1:ECDSA+SHA1\\nShared Requested Signature Algorithms: RSA-PSS+SHA256:ECDSA+SHA256:ed25519:RSA-PSS+SHA384:RSA-PSS+SHA512:RSA+SHA256:RSA+SHA384:RSA+SHA512:ECDSA+SHA384:ECDSA+SHA512\\nPeer signing digest: SHA256\\nPeer signature type: ECDSA\\nServer Temp Key: X25519, 253 bits\\n---\\nSSL handshake has read 3874 bytes and written 439 bytes\\nVerification: OK\\n---\\nNew, TLSv1.3, Cipher is TLS_AES_128_GCM_SHA256\\nServer public key is 256 bit\\nThis TLS version forbids renegotiation.\\nNo ALPN negotiated\\nEarly data was not sent\\nVerify return code: 0 (ok)\\n---\\n-----BEGIN CERTIFICATE-----\\nMIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw\\nTzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh\\ncmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4\\nWhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu\\nZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY\\nMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc\\nh77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+\\n0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U\\nA5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW\\nT8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH\\nB5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC\\nB5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv\\nKBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn\\nOlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn\\njh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw\\nqHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI\\nrU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV\\nHRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq\\nhkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL\\nubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ\\n3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK\\nNFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5\\nORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur\\nTkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC\\njNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc\\noyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq\\n4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA\\nmRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d\\nemyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=\\n-----END CERTIFICATE-----\\n", + Fields = new Dictionary() + { + { "Gear", new TelemetryFieldConfig() { IntervalSeconds = 60 } }, + { "Locked", new TelemetryFieldConfig() { IntervalSeconds = 60 } }, + { "DriverSeatOccupied", new TelemetryFieldConfig() { IntervalSeconds = 60 } }, + { "GpsState", new TelemetryFieldConfig() { IntervalSeconds = 60 } }, + { "Location", new TelemetryFieldConfig() { IntervalSeconds = 60 } }, + } + } + }; + + HttpResponseMessage response = await client.PostAsJsonAsync("/api/1/vehicles/fleet_telemetry_config", configRequest); + return Results.Ok(response.Content.ReadAsStringAsync()); + }); } //Map static assets diff --git a/Source/ProofOfConcept/Utilities/CertificateAuthority.cs b/Source/ProofOfConcept/Utilities/CertificateAuthority.cs new file mode 100644 index 0000000..5c81c80 --- /dev/null +++ b/Source/ProofOfConcept/Utilities/CertificateAuthority.cs @@ -0,0 +1,87 @@ +namespace ProofOfConcept.Utilities; + +using System.Net; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +public static class CertificateAuthority +{ + /// + /// Create a self-signed Root CA certificate. + /// + /// Distinguished Name (e.g., "CN=My Root CA, O=MyOrg, C=HU"). + /// Validity in years. + /// X509Certificate2 with private key. + public static X509Certificate2 CreateRootCA(string subjectName, int validYears = 10) + { + using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + + var req = new CertificateRequest( + new X500DistinguishedName(subjectName), + key, + HashAlgorithmName.SHA256); + + req.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, true, 1, true)); + req.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, true)); + req.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(req.PublicKey, false)); + + var notBefore = DateTimeOffset.UtcNow.AddMinutes(-5); + var notAfter = notBefore.AddYears(validYears); + + var cert = req.CreateSelfSigned(notBefore, notAfter); + + // Attach private key so we can export + return cert.CopyWithPrivateKey(key); + } + + /// + /// Create a TLS/HTTPS certificate for a domain, signed by the given Root CA. + /// + /// Root CA certificate (must include private key). + /// Common Name (e.g., "CN=myapp.local"). + /// DNS SAN entries. + /// Optional IP SAN entries. + /// Validity in days (max ~397 for Apple clients). + /// X509Certificate2 with private key. + public static X509Certificate2 CreateHttpsCertificate(X509Certificate2 rootCA, string subjectName, IEnumerable dnsNames, IEnumerable? ipAddresses = null, int validDays = 397) + { + if (!rootCA.HasPrivateKey) + throw new ArgumentException("Root CA must have a private key", nameof(rootCA)); + + using var leafKey = ECDsa.Create(ECCurve.NamedCurves.nistP256); + + var req = new CertificateRequest( + new X500DistinguishedName(subjectName), + leafKey, + HashAlgorithmName.SHA256); + + // Basic constraints: not a CA + req.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true)); + req.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, true)); + req.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension( + new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") }, true)); // serverAuth + req.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(req.PublicKey, false)); + + // Subject Alternative Names + var sanBuilder = new SubjectAlternativeNameBuilder(); + foreach (var dns in dnsNames) + sanBuilder.AddDnsName(dns); + if (ipAddresses != null) + { + foreach (var ip in ipAddresses) + sanBuilder.AddIpAddress(ip); + } + req.CertificateExtensions.Add(sanBuilder.Build()); + + var notBefore = DateTimeOffset.UtcNow.AddMinutes(-5); + var notAfter = notBefore.AddDays(validDays); + + // Generate random serial + var serial = new byte[16]; + RandomNumberGenerator.Fill(serial); + serial[0] &= 0x7F; // positive + + var issued = req.Create(rootCA, notBefore, notAfter, serial); + return issued.CopyWithPrivateKey(leafKey); + } +} \ No newline at end of file