diff --git a/sources/Jpki.NUnit/AssertThrows.cs b/sources/Jpki.NUnit/AssertThrows.cs index 69fc4b0..d107db1 100644 --- a/sources/Jpki.NUnit/AssertThrows.cs +++ b/sources/Jpki.NUnit/AssertThrows.cs @@ -19,7 +19,6 @@ // under the License. // -using NUnit.Framework; using System; using System.Reflection; @@ -51,7 +50,9 @@ private static Exception Unwrap(this Exception e) { try { + #pragma warning disable VSTHRD002 // Avoid problematic synchronous waits code().Wait(); + #pragma warning restore VSTHRD002 } catch (AggregateException e) { diff --git a/sources/Jpki.Powershell.Test/Runtime/Http/TestRestClient.cs b/sources/Jpki.Powershell.Test/Runtime/Http/TestJsonResource.cs similarity index 52% rename from sources/Jpki.Powershell.Test/Runtime/Http/TestRestClient.cs rename to sources/Jpki.Powershell.Test/Runtime/Http/TestJsonResource.cs index 55aadc0..c264a6a 100644 --- a/sources/Jpki.Powershell.Test/Runtime/Http/TestRestClient.cs +++ b/sources/Jpki.Powershell.Test/Runtime/Http/TestJsonResource.cs @@ -22,6 +22,7 @@ using Jpki.Powershell.Runtime.Http; using NUnit.Framework; using System; +using System.Net; using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -36,9 +37,9 @@ namespace Jpki.Powershell.Test.Runtime.Http { [TestFixture] - public class TestRestClient + public class TestJsonResource { - private static readonly Uri SampleRestUrl = + private static readonly Uri SampleUrl = new Uri("https://accounts.google.com/.well-known/openid-configuration"); private static readonly Uri NotFoundUrl = @@ -47,82 +48,54 @@ public class TestRestClient private static readonly Uri NoContentUrl = new Uri("https://gstatic.com/generate_204"); - private static readonly UserAgent userAgent = new UserAgent( - "test", - new Version(1, 0), - Environment.OSVersion.VersionString); - - public class SampleResource + public class Body { [JsonPropertyName("issuer")] public string? Issuer { get; set; } } //--------------------------------------------------------------------- - // GetString. + // Get. //--------------------------------------------------------------------- [Test] - public async Task WhenUrlPointsToNoContent_ThenGetStringReturnsEmptyString() + public async Task GetContent() { var client = new RestClient(); - var result = await client - .GetStringAsync( - NoContentUrl, - CancellationToken.None) + var response = await client + .Resource>(SampleUrl) + .Get() + .ExecuteAsync(CancellationToken.None) .ConfigureAwait(false); - AssertThat.AreEqual(string.Empty, result); - } - - [Test] - public void WhenUrlNotFound_ThenGetStringThrowsException() - { - var client = new RestClient(); - AssertThrows.AggregateException( - () => client.GetStringAsync( - NotFoundUrl, - CancellationToken.None)); + AssertThat.AreEqual(HttpStatusCode.OK, response.StatusCode); + AssertThat.IsNotNull(response.Body?.Issuer); } - //--------------------------------------------------------------------- - // GetJson. - //--------------------------------------------------------------------- - [Test] - public async Task WhenUrlPointsToJson_ThenGetJsonReturnsObject() + public async Task GetNoContent() { var client = new RestClient(); - var result = await client - .GetJsonAsync( - SampleRestUrl, - CancellationToken.None) + var response = await client + .Resource>(NoContentUrl) + .Get() + .ExecuteAsync(CancellationToken.None) .ConfigureAwait(false); - AssertThat.IsNotNull(result?.Issuer); + AssertThat.AreEqual(HttpStatusCode.NoContent, response.StatusCode); + AssertThat.IsNull(response.Body); } [Test] - public async Task WhenUrlPointsToNoContent_ThenGetJsonReturnsNull() + public void GetNotFound() { var client = new RestClient(); - var result = await client - .GetJsonAsync( - NoContentUrl, - CancellationToken.None) - .ConfigureAwait(false); - - AssertThat.IsNull(result); - } - [Test] - public void WhenUrlNotFound_ThenGetJsonThrowsException() - { - var client = new RestClient(); AssertThrows.AggregateException( - () => client.GetJsonAsync( - NotFoundUrl, - CancellationToken.None)); + () => client + .Resource>(NotFoundUrl) + .Get() + .ExecuteAsync(CancellationToken.None)); } } } diff --git a/sources/Jpki.Powershell.Test/Runtime/Http/TestTextResource.cs b/sources/Jpki.Powershell.Test/Runtime/Http/TestTextResource.cs new file mode 100644 index 0000000..776210d --- /dev/null +++ b/sources/Jpki.Powershell.Test/Runtime/Http/TestTextResource.cs @@ -0,0 +1,105 @@ +// +// Copyright 2024 Johannes Passing +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// + +using Jpki.Powershell.Runtime.Http; +using NUnit.Framework; +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace Jpki.Powershell.Test.Runtime.Http +{ + [TestFixture] + public class TestHtmlResource + { + private static readonly Uri SampleUrl = + new Uri("https://accounts.google.com/"); + + private static readonly Uri NotFoundUrl = + new Uri("https://gstatic.com/generate_404"); + + private static readonly Uri NoContentUrl = + new Uri("https://gstatic.com/generate_204"); + + private class HtmlResource : TextResource + { + public override string ExpectedContentType => "text/html"; + } + + //--------------------------------------------------------------------- + // Get. + //--------------------------------------------------------------------- + + [Test] + public async Task GetContent() + { + var client = new RestClient(); + var response = await client + .Resource(SampleUrl) + .Get() + .ExecuteAsync(CancellationToken.None) + .ConfigureAwait(false); + + AssertThat.AreEqual(HttpStatusCode.OK, response.StatusCode); + AssertThat.IsNotNull(response.Body); + } + + [Test] + public async Task GetNoContent() + { + var client = new RestClient(); + var response = await client + .Resource(NoContentUrl) + .Get() + .ExecuteAsync(CancellationToken.None) + .ConfigureAwait(false); + + AssertThat.AreEqual(HttpStatusCode.NoContent, response.StatusCode); + AssertThat.AreEqual(string.Empty, response.Body); + } + + [Test] + public void GetUnexpectedContentType() + { + var client = new RestClient(); + + AssertThrows.AggregateException( + () => client + .Resource(SampleUrl) + .Get() + .ExecuteAsync(CancellationToken.None)); + } + + [Test] + public void GetNotFound() + { + var client = new RestClient(); + + AssertThrows.AggregateException( + () => client + .Resource(NotFoundUrl) + .Get() + .ExecuteAsync(CancellationToken.None)); + } + } +} diff --git a/sources/Jpki.Powershell.Test/Runtime/TestLinqExtensions.cs b/sources/Jpki.Powershell.Test/Runtime/TestLinqExtensions.cs new file mode 100644 index 0000000..9fbaa50 --- /dev/null +++ b/sources/Jpki.Powershell.Test/Runtime/TestLinqExtensions.cs @@ -0,0 +1,44 @@ +// +// Copyright 2024 Johannes Passing +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// + +using Jpki.Powershell.Runtime; +using NUnit.Framework; +using System.Collections.Generic; +using System.Linq; + +namespace Jpki.Powershell.Test.Runtime +{ + [TestFixture] + public class TestLinqExtensions + { + //--------------------------------------------------------------------- + // EnsureNotNull. + //--------------------------------------------------------------------- + + [Test] + public void WhenEnumIsNull_EnsureNotNullReturnsEmpty() + { + IEnumerable? e = null; + AssertThat.IsNotNull(e.EnsureNotNull()); + AssertThat.AreEqual(0, e.EnsureNotNull().Count()); + } + } +} diff --git a/sources/Jpki.Powershell.Test/Security/WebAuthn/Metadata/MdsSampleData.cs b/sources/Jpki.Powershell.Test/Security/WebAuthn/Metadata/MdsSampleData.cs new file mode 100644 index 0000000..73d514a --- /dev/null +++ b/sources/Jpki.Powershell.Test/Security/WebAuthn/Metadata/MdsSampleData.cs @@ -0,0 +1,292 @@ +// +// Copyright 2024 Johannes Passing +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// + +namespace Jpki.Powershell.Test.Security.WebAuthn.Metadata +{ + /// + /// Sample data taken from the MDS. + /// + internal static class MdsSampleData + { + internal static class MetadataStatements + { + internal const string YubiKey5ci = @"{ + ""legalHeader"": ""..."", + ""attestationCertificateKeyIdentifiers"": [ + ""bf7bcaa0d0c6187a8c6abbdd16a15640e7c7bde2"", + ""3012b66092a16d3d7687241634b20a3bde2634e8"", + ""753300d65dcc73a39a7db31ef308db9fa0b566ae"", + ""98552aea456370e22e1901e31817359142b92888"", + ""b753a0e460fb2dc7c7c487e35f24cf63b065347c"", + ""b6d44a4b8d4b0407872969b1f6b2263021be627e"", + ""6d491f223af73cdf81784a6c0890f8a1d527a12c"" + ], + ""description"": ""YubiKey 5 Series with Lightning"", + ""authenticatorVersion"": 2, + ""protocolFamily"": ""u2f"", + ""schema"": 3, + ""upv"": [ + { + ""major"": 1, + ""minor"": 1 + } + ], + ""authenticationAlgorithms"": [ + ""secp256r1_ecdsa_sha256_raw"" + ], + ""publicKeyAlgAndEncodings"": [ + ""ecc_x962_raw"" + ], + ""attestationTypes"": [ + ""basic_full"" + ], + ""userVerificationDetails"": [ + [ + { + ""userVerificationMethod"": ""presence_internal"" + } + ] + ], + ""keyProtection"": [ + ""hardware"", + ""secure_element"", + ""remote_handle"" + ], + ""matcherProtection"": [ + ""on_chip"" + ], + ""cryptoStrength"": 128, + ""attachmentHint"": [ + ""external"", + ""wired"" + ], + ""tcDisplay"": [], + ""attestationRootCertificates"": [ + ""MIIDHjCCAgagAwIBAgIEG0BT9zANBgkqhkiG9w0BAQsFADAuMSwwKgYDVQQDEyNZdWJpY28gVTJGIFJvb3QgQ0EgU2VyaWFsIDQ1NzIwMDYzMTAgFw0xNDA4MDEwMDAwMDBaGA8yMDUwMDkwNDAwMDAwMFowLjEsMCoGA1UEAxMjWXViaWNvIFUyRiBSb290IENBIFNlcmlhbCA0NTcyMDA2MzEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC/jwYuhBVlqaiYWEMsrWFisgJ+PtM91eSrpI4TK7U53mwCIawSDHy8vUmk5N2KAj9abvT9NP5SMS1hQi3usxoYGonXQgfO6ZXyUA9a+KAkqdFnBnlyugSeCOep8EdZFfsaRFtMjkwz5Gcz2Py4vIYvCdMHPtwaz0bVuzneueIEz6TnQjE63Rdt2zbwnebwTG5ZybeWSwbzy+BJ34ZHcUhPAY89yJQXuE0IzMZFcEBbPNRbWECRKgjq//qT9nmDOFVlSRCt2wiqPSzluwn+v+suQEBsUjTGMEd25tKXXTkNW21wIWbxeSyUoTXwLvGS6xlwQSgNpk2qXYwf8iXg7VWZAgMBAAGjQjBAMB0GA1UdDgQWBBQgIvz0bNGJhjgpToksyKpP9xv9oDAPBgNVHRMECDAGAQH/AgEAMA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQsFAAOCAQEAjvjuOMDSa+JXFCLyBKsycXtBVZsJ4Ue3LbaEsPY4MYN/hIQ5ZM5p7EjfcnMG4CtYkNsfNHc0AhBLdq45rnT87q/6O3vUEtNMafbhU6kthX7Y+9XFN9NpmYxr+ekVY5xOxi8h9JDIgoMP4VB1uS0aunL1IGqrNooL9mmFnL2kLVVee6/VR6C5+KSTCMCWppMuJIZII2v9o4dkoZ8Y7QRjQlLfYzd3qGtKbw7xaF1UsG/5xUb/Btwb2X2g4InpiB/yt/3CpQXpiWX/K4mBvUKiGn05ZsqeY1gx4g0xLBqcU9psmyPzK+Vsgw2jeRQ5JlKDyqE0hebfC1tvFu0CCrJFcw=="" + ], + ""icon"": ""data:image/png;base64,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"" + }"; + + internal const string YubiKey5Nfc = @"{ + ""legalHeader"": ""..."", + ""aaguid"": ""fa2b99dc-9e39-4257-8f92-4a30d23c4118"", + ""description"": ""YubiKey 5 Series with NFC"", + ""authenticatorVersion"": 50100, + ""protocolFamily"": ""fido2"", + ""schema"": 3, + ""upv"": [ + { + ""major"": 1, + ""minor"": 0 + } + ], + ""authenticationAlgorithms"": [ + ""ed25519_eddsa_sha512_raw"", + ""secp256r1_ecdsa_sha256_raw"" + ], + ""publicKeyAlgAndEncodings"": [ + ""cose"" + ], + ""attestationTypes"": [ + ""basic_full"" + ], + ""userVerificationDetails"": [ + [ + { + ""userVerificationMethod"": ""passcode_external"", + ""caDesc"": { + ""base"": 64, + ""minLength"": 4, + ""maxRetries"": 8, + ""blockSlowdown"": 0 + } + } + ], + [ + { + ""userVerificationMethod"": ""none"" + } + ], + [ + { + ""userVerificationMethod"": ""passcode_external"" + }, + { + ""userVerificationMethod"": ""presence_internal"", + ""caDesc"": { + ""base"": 64, + ""minLength"": 4, + ""maxRetries"": 8, + ""blockSlowdown"": 0 + } + } + ], + [ + { + ""userVerificationMethod"": ""presence_internal"" + } + ] + ], + ""keyProtection"": [ + ""hardware"", + ""secure_element"" + ], + ""matcherProtection"": [ + ""on_chip"" + ], + ""cryptoStrength"": 128, + ""attachmentHint"": [ + ""external"", + ""wired"", + ""wireless"", + ""nfc"" + ], + ""tcDisplay"": [], + ""attestationRootCertificates"": [ + ""MIIDHjCCAgagAwIBAgIEG0BT9zANBgkqhkiG9w0BAQsFADAuMSwwKgYDVQQDEyNZdWJpY28gVTJGIFJvb3QgQ0EgU2VyaWFsIDQ1NzIwMDYzMTAgFw0xNDA4MDEwMDAwMDBaGA8yMDUwMDkwNDAwMDAwMFowLjEsMCoGA1UEAxMjWXViaWNvIFUyRiBSb290IENBIFNlcmlhbCA0NTcyMDA2MzEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC/jwYuhBVlqaiYWEMsrWFisgJ+PtM91eSrpI4TK7U53mwCIawSDHy8vUmk5N2KAj9abvT9NP5SMS1hQi3usxoYGonXQgfO6ZXyUA9a+KAkqdFnBnlyugSeCOep8EdZFfsaRFtMjkwz5Gcz2Py4vIYvCdMHPtwaz0bVuzneueIEz6TnQjE63Rdt2zbwnebwTG5ZybeWSwbzy+BJ34ZHcUhPAY89yJQXuE0IzMZFcEBbPNRbWECRKgjq//qT9nmDOFVlSRCt2wiqPSzluwn+v+suQEBsUjTGMEd25tKXXTkNW21wIWbxeSyUoTXwLvGS6xlwQSgNpk2qXYwf8iXg7VWZAgMBAAGjQjBAMB0GA1UdDgQWBBQgIvz0bNGJhjgpToksyKpP9xv9oDAPBgNVHRMECDAGAQH/AgEAMA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQsFAAOCAQEAjvjuOMDSa+JXFCLyBKsycXtBVZsJ4Ue3LbaEsPY4MYN/hIQ5ZM5p7EjfcnMG4CtYkNsfNHc0AhBLdq45rnT87q/6O3vUEtNMafbhU6kthX7Y+9XFN9NpmYxr+ekVY5xOxi8h9JDIgoMP4VB1uS0aunL1IGqrNooL9mmFnL2kLVVee6/VR6C5+KSTCMCWppMuJIZII2v9o4dkoZ8Y7QRjQlLfYzd3qGtKbw7xaF1UsG/5xUb/Btwb2X2g4InpiB/yt/3CpQXpiWX/K4mBvUKiGn05ZsqeY1gx4g0xLBqcU9psmyPzK+Vsgw2jeRQ5JlKDyqE0hebfC1tvFu0CCrJFcw=="" + ], + ""icon"": ""data:image/png;base64,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"", + ""authenticatorGetInfo"": { + ""versions"": [ + ""U2F_V2"", + ""FIDO_2_0"" + ], + ""extensions"": [ + ""hmac-secret"" + ], + ""aaguid"": ""fa2b99dc9e3942578f924a30d23c4118"", + ""options"": { + ""plat"": false, + ""rk"": true, + ""clientPin"": true, + ""up"": true + }, + ""maxMsgSize"": 1200, + ""pinUvAuthProtocols"": [ + 1 + ] + } + }"; + + } + + internal static class MetadataBlobs + { + public const string GoogleTitanV2 = @"{ + ""legalHeader"": ""..."", + ""no"": 64, + ""nextUpdate"": ""2024-04-01"", + ""entries"": [ + { + ""aaguid"": ""42b4fb4a-2866-43b2-9bf7-6c6669c2e5d3"", + ""metadataStatement"": { + ""legalHeader"": ""Submission of ..."", + ""aaguid"": ""42b4fb4a-2866-43b2-9bf7-6c6669c2e5d3"", + ""description"": ""Google Titan Security Key v2"", + ""authenticatorVersion"": 1, + ""protocolFamily"": ""fido2"", + ""schema"": 3, + ""upv"": [ + { + ""major"": 1, + ""minor"": 0 + } + ], + ""authenticationAlgorithms"": [ ""secp256r1_ecdsa_sha256_raw"" ], + ""publicKeyAlgAndEncodings"": [ ""ecc_x962_raw"", ""cose"" ], + ""attestationTypes"": [ ""basic_full"" ], + ""userVerificationDetails"": [ + [ + { ""userVerificationMethod"": ""presence_internal"" }, + { + ""userVerificationMethod"": ""passcode_external"", + ""caDesc"": { + ""base"": 10, + ""minLength"": 4, + ""maxRetries"": 0, + ""blockSlowdown"": 0 + } + } + ], + [ { ""userVerificationMethod"": ""presence_internal"" } ], + [ { ""userVerificationMethod"": ""none"" } ], + [ + { + ""userVerificationMethod"": ""passcode_external"", + ""caDesc"": { + ""base"": 10, + ""minLength"": 4, + ""maxRetries"": 0, + ""blockSlowdown"": 0 + } + } + ] + ], + ""keyProtection"": [ ""hardware"", ""secure_element"" ], + ""matcherProtection"": [ ""on_chip"" ], + ""cryptoStrength"": 128, + ""attachmentHint"": [ ""external"", ""wired"", ""wireless"", ""nfc"" ], + ""tcDisplay"": [], + ""attestationRootCertificates"": [ ""MIICIjCCAcigAwIBAgIBAjAKBggqhkjOPQQDAjBkMQswCQYDVQQGEwJVUzEPMA0GA1UECgwGR29vZ2xlMSIwIAYDVQQLDBlBdXRoZW50aWNhdG9yIEF0dGVzdGF0aW9uMSAwHgYDVQQDDBdUaXRhbiBTZWN1cml0eSBLZXkgUm9vdDAgFw0yMTEyMDExNTI2MzFaGA8yMTIxMTIwMjE1MjYzMVowZzELMAkGA1UEBhMCVVMxDzANBgNVBAoMBkdvb2dsZTEiMCAGA1UECwwZQXV0aGVudGljYXRvciBBdHRlc3RhdGlvbjEjMCEGA1UEAwwaVGl0YW4gU2VjdXJpdHkgS2V5IFNpZ25pbmcwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARGSX/0WmoStYfhmlzSPB4SARhmTBpPi0o3yYygS4smn/4OFdGNJdsPxkub62pOlWe0I6cJSh9W3EAHA2ZPO+S+o2YwZDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQURTqQYOsPJ897X40vav+XoW+S6sgwHwYDVR0jBBgwFoAU2d6JrFCoEZAe/LUpIMybltDsMh0wCgYIKoZIzj0EAwIDSAAwRQIgSr3N14HdtCfj7QZ0R7kWg6I317QENb8q+fbNko6nK4oCIQD5Jh14grDc6F7gHib9QTv8sUs6w8gF1JYKMK+LDOYPYg=="", ""MIICMjCCAdmgAwIBAgIBATAKBggqhkjOPQQDAjBkMQswCQYDVQQGEwJVUzEPMA0GA1UECgwGR29vZ2xlMSIwIAYDVQQLDBlBdXRoZW50aWNhdG9yIEF0dGVzdGF0aW9uMSAwHgYDVQQDDBdUaXRhbiBTZWN1cml0eSBLZXkgUm9vdDAgFw0yMTEyMDExNTIzNTFaGA8yMTIxMTIwMjE1MjM1MVowZDELMAkGA1UEBhMCVVMxDzANBgNVBAoMBkdvb2dsZTEiMCAGA1UECwwZQXV0aGVudGljYXRvciBBdHRlc3RhdGlvbjEgMB4GA1UEAwwXVGl0YW4gU2VjdXJpdHkgS2V5IFJvb3QwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARqmNWzcDNH63o8TzodB2jk9b49VPsfIvXpdhaWxfLayo4LBbDrXyxF3JR1P6W6ZsqWCEYrX0oYIxAog3hCE4ydo3oweDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU2d6JrFCoEZAe/LUpIMybltDsMh0wHwYDVR0jBBgwFoAU2d6JrFCoEZAe/LUpIMybltDsMh0wFQYLKwYBBAGC5RwCAQEEBgQEAwIAADAKBggqhkjOPQQDAgNHADBEAiANIQ48/nMp2KfYNiovcyxWXJLiul4Sv+zcRJezrd/WWAIgVucQ531fqzY7ODoK+dIDykRudvlW/yBqza/AdS0Sq6Q="" ], + ""icon"": ""data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAD1ElEQVR4AeyXU5gcTRSG62L927b/aJLpnt241zGmd2Pbxl1s29Yotm3bdqY3nN5oUakTq43leZ5v2Xi/qoMalBkiO84xTND1qNAwbyTdkshBtM4bZTtFvp97Tdu4SHo6UVu4Fu5Jc3AA4aKo0QTuNhFWKI5oPDzDdHACzcCKAog+gh2zFjIc/CYT+iN52TIibIQgxW4zlk8NSheq7PNtxwbrku5p5Y2gu8PDTdQDLspWUh/4KHqwqfCgCPqYl6G/NXLl0z/8jSiK1Qhz+7UZwJkKnxBj/dcbafMpBE56PsQqvq+TwN+eL4oDrjUMHoKLpFcphJ+s5OXXmLBfyQJ5DIGHdqlkml6PpiORyoCjB4E/pBs8Xof8+EHfnuCKWVNkwF+DVNP8TobxQ3pF8qoANmm1P37o/AgnxOcRgbf5rsdQOVF6i6TVAcvAAOjx0kB8p/HfQiYqpjd2SJ8vCXgSwL8uvucP2BtNvQ6/DKXHSF4dUBGA36cHkz7DCWUtAI+5aBuVPg2s8h8OsEJ6ND8EUuooSq9BILcBqLj82iKFEdGTx3oqvAe/TKiAr0kZeLzSn0pjA6BzQjuApUQK/cN0YCBJtQEEkfYGcJY1ACkUlJ4NcDKK2JKeDeySNLDav2E6MHBJYBL7jxeD51cFJfeel2+pCgOT5Qp6vOQc6MWvEjJQUwj+9IrPcAVPDKacLLbOYv9FBkVkT76t5A70ShwucJgL/vF98CuW/IyLuMoC/DM52OnGGfBtkzIQ2cNXUew4sekF+MPVAbjfgrwA/Y5oR1yk3vDhPRLLyqkBph//LYIQS6PrKz/CdWczACuka6Ez7D/qBc908X5I4I5J5n/PxE1SnwmCNi79/kasuyRASukY7Y7X5bNsRE/fPDmrT1KsKZIKymGvC4AydYpyls+paeV78Itkts/bTBcsb5ASsF0KTDygXPbOzORaiqZ0PhcbGzax65HwPl6Z/T+xs/yHO+1hBCwJAOXLztFOtjfcK/hcd/yflINtSq7b9uI+2/TGmBlwVPIILbD6wgEvgheo1G2ifUTrQAAMBoWup2dVwYWGLRcpXj4WqQmrk50MLzBLBcaOJIPq7psGevi6q29v6xg/yhXnMdOEbWo7HN733Iu895DU8QMWTSZg+pppgp5V7XHRwVtfwOsTJI9bQscxx0TcYFg4pHeQwWUhLzhkGDgUuotlkZEBK2N1sTVJgZ/TEf4BeWZ/y7xynyKzAgYX7bBXJEW+THpmCOqU1RHX0Tqr8pcoLQMOdmAGchd6/vPdSXonPWDCPxmwQADibHC/YhiAUQAA0S0KWSVGA04AAAAASUVORK5CYII="", + ""supportedExtensions"": [ + { + ""id"": ""hmac-secret"", + ""fail_if_unknown"": false + }, + { + ""id"": ""credProtect"", + ""fail_if_unknown"": false + } + ], + ""authenticatorGetInfo"": { + ""versions"": [ ""FIDO_2_0"", ""U2F_V2"" ], + ""extensions"": [ ""credProtect"", ""hmac-secret"" ], + ""aaguid"": ""42b4fb4a286643b29bf76c6669c2e5d3"", + ""options"": { + ""rk"": true, + ""clientPin"": false + }, + ""maxMsgSize"": 2200, + ""pinUvAuthProtocols"": [ 1 ] + } + }, + ""statusReports"": [ + { + ""status"": ""FIDO_CERTIFIED_L1"", + ""effectiveDate"": ""2023-06-12"", + ""certificationDescriptor"": ""Google Titan Security Key v2"", + ""certificateNumber"": ""FIDO20020230612002"", + ""certificationPolicyVersion"": ""1.4.0"", + ""certificationRequirementsVersion"": ""1.5.0"" + }, + { + ""status"": ""FIDO_CERTIFIED"", + ""effectiveDate"": ""2023-06-12"" + } + ], + ""timeOfLastStatusChange"": ""2023-09-03"" + } + ] + }"; + } + } +} \ No newline at end of file diff --git a/sources/Jpki.Powershell.Test/Security/WebAuthn/Metadata/TestMetadataBlob.cs b/sources/Jpki.Powershell.Test/Security/WebAuthn/Metadata/TestMetadataBlob.cs new file mode 100644 index 0000000..0947002 --- /dev/null +++ b/sources/Jpki.Powershell.Test/Security/WebAuthn/Metadata/TestMetadataBlob.cs @@ -0,0 +1,63 @@ +// +// Copyright 2024 Johannes Passing +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// + +using Jpki.Powershell.Runtime.Text; +using Jpki.Security.WebAuthn.Metadata; +using NUnit.Framework; +using System; +using System.Linq; + +namespace Jpki.Powershell.Test.Security.WebAuthn.Metadata +{ + [TestFixture] + public class TestMetadataBlob + { + [Test] + public void GoogleTitanV2() + { + var blob = Json.Deserialize( + MdsSampleData.MetadataBlobs.GoogleTitanV2)!; + + AssertThat.NotNull(blob); + AssertThat.AreEqual("...", blob.LegalHeader); + AssertThat.AreEqual(64, blob.No); + AssertThat.AreEqual(2024, blob.NextUpdate!.Value.Year); + AssertThat.AreEqual(1, blob.Entries!.Count); + + var entry = blob.Entries!.First(); + + AssertThat.AreEqual( + new Guid("42b4fb4a-2866-43b2-9bf7-6c6669c2e5d3"), + entry.Aaguid); + AssertThat.AreEqual( + new Guid("42b4fb4a-2866-43b2-9bf7-6c6669c2e5d3"), + entry.MetadataStatement!.Aaguid); + + AssertThat.AreEqual(2, entry.StatusReports!.Count); + AssertThat.AreEqual(AuthenticatorStatus.FIDO_CERTIFIED_L1, entry.StatusReports[0].Status); + AssertThat.AreEqual("FIDO20020230612002", entry.StatusReports[0].CertificateNumber); + AssertThat.AreEqual("1.4.0", entry.StatusReports[0].CertificationPolicyVersion); + AssertThat.AreEqual("1.5.0", entry.StatusReports[0].CertificationRequirementsVersion); + + AssertThat.AreEqual(AuthenticatorStatus.FIDO_CERTIFIED, entry.StatusReports[1].Status); + } + } +} diff --git a/sources/Jpki.Powershell.Test/Security/WebAuthn/Metadata/TestMetadataStatement.cs b/sources/Jpki.Powershell.Test/Security/WebAuthn/Metadata/TestMetadataStatement.cs new file mode 100644 index 0000000..12b5343 --- /dev/null +++ b/sources/Jpki.Powershell.Test/Security/WebAuthn/Metadata/TestMetadataStatement.cs @@ -0,0 +1,126 @@ +// +// Copyright 2024 Johannes Passing +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// + +using Jpki.Powershell.Runtime.Text; +using Jpki.Security.WebAuthn.Metadata; +using NUnit.Framework; +using System; +using System.Linq; + +namespace Jpki.Powershell.Test.Security.WebAuthn.Metadata +{ + [TestFixture] + public class TestMetadataStatement + { + [Test] + public void YubiKey5ci() + { + var statement = Json.Deserialize( + MdsSampleData.MetadataStatements.YubiKey5ci)!; + + AssertThat.NotNull(statement); + AssertThat.AreEqual("...", statement.LegalHeader); + AssertThat.AreEqual("YubiKey 5 Series with Lightning", statement.Description); + CollectionAssertThat.AreEquivalent( + new[] { "secp256r1_ecdsa_sha256_raw" }, + statement.AuthenticationAlgorithms!); + CollectionAssertThat.AreEquivalent( + new[] { "hardware", "secure_element", "remote_handle" }, + statement.KeyProtection!); + AssertThat.AreEqual("u2f", statement.ProtocolFamily); + AssertThat.AreEqual(1, statement!.Upv![0].Major); + AssertThat.AreEqual(1, statement!.Upv![0].Minor); + CollectionAssertThat.AreEquivalent( + new[] { new[] { new MetadataStatement.UserVerificationDescriptor("presence_internal") } }, + statement.UserVerificationDetails!); + + AssertThat.IsNull(statement.AuthenticatorGetInfo); + CollectionAssertThat.AreEquivalent( + new[] { "hardware", "secure_element", "remote_handle" }, + statement.KeyProtection!); + CollectionAssertThat.AreEquivalent( + new[] { "on_chip" }, + statement.MatcherProtection!); + AssertThat.AreEqual(128, statement.CryptoStrength); + AssertThat.AreEqual( + "CN=Yubico U2F Root CA Serial 457200631", + statement.AttestationRootCertificates.First().Issuer); + } + + [Test] + public void YubiKey5Nfc() + { + var statement = Json.Deserialize( + MdsSampleData.MetadataStatements.YubiKey5Nfc)!; + + AssertThat.NotNull(statement); + AssertThat.AreEqual("...", statement.LegalHeader); + AssertThat.AreEqual(Guid.Parse("fa2b99dc-9e39-4257-8f92-4a30d23c4118"), statement.Aaguid); + AssertThat.AreEqual("YubiKey 5 Series with NFC", statement.Description); + CollectionAssertThat.AreEquivalent( + new[] { "ed25519_eddsa_sha512_raw", "secp256r1_ecdsa_sha256_raw" }, + statement.AuthenticationAlgorithms!); + CollectionAssertThat.AreEquivalent( + new[] { "hardware", "secure_element" }, + statement.KeyProtection!); + AssertThat.AreEqual("fido2", statement.ProtocolFamily); + AssertThat.AreEqual(1, statement!.Upv![0].Major); + AssertThat.AreEqual(0, statement!.Upv![0].Minor); + + AssertThat.AreEqual( + new MetadataStatement.UserVerificationDescriptor("passcode_external"), + statement.UserVerificationDetails![0][0]); + AssertThat.AreEqual( + new MetadataStatement.UserVerificationDescriptor("none"), + statement.UserVerificationDetails![1][0]); + AssertThat.AreEqual( + new MetadataStatement.UserVerificationDescriptor("passcode_external"), + statement.UserVerificationDetails![2][0]); + AssertThat.AreEqual( + new MetadataStatement.UserVerificationDescriptor("presence_internal"), + statement.UserVerificationDetails![2][1]); + AssertThat.AreEqual( + new MetadataStatement.UserVerificationDescriptor("presence_internal"), + statement.UserVerificationDetails![3][0]); + + AssertThat.IsNotNull(statement.UserVerificationDetails![0]![0]!.CodeAccuracy); + AssertThat.AreEqual(64, statement.UserVerificationDetails![0]![0]!.CodeAccuracy!.Base); + + AssertThat.IsNotNull(statement.AuthenticatorGetInfo); + var authenticatorGetInfo = statement.AuthenticatorGetInfo!; + AssertThat.AreEqual(statement.Aaguid, authenticatorGetInfo!.Aaguid); + CollectionAssertThat.AreEquivalent( + new[] { "hmac-secret" }, + authenticatorGetInfo.Extensions!); + AssertThat.AreEqual(1200, authenticatorGetInfo.MaxMsgSize); + AssertThat.AreEqual(false, authenticatorGetInfo.Options!["plat"]); + AssertThat.AreEqual(true, authenticatorGetInfo.Options["rk"]); + AssertThat.AreEqual(true, authenticatorGetInfo.Options["clientPin"]); + AssertThat.AreEqual(true, authenticatorGetInfo.Options["up"]); + CollectionAssertThat.AreEquivalent( + new[] { 1 }, + authenticatorGetInfo.PinUvAuthProtocols!); + AssertThat.AreEqual( + "CN=Yubico U2F Root CA Serial 457200631", + statement.AttestationRootCertificates.First().Issuer); + } + } +} diff --git a/sources/Jpki.Powershell/Runtime/Http/JsonResource.cs b/sources/Jpki.Powershell/Runtime/Http/JsonResource.cs new file mode 100644 index 0000000..1a977ca --- /dev/null +++ b/sources/Jpki.Powershell/Runtime/Http/JsonResource.cs @@ -0,0 +1,97 @@ +// +// Copyright 2024 Johannes Passing +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// + +using Jpki.Powershell.Runtime.Text; +using System.Net.Http; +using System.Threading.Tasks; +using System.Threading; +using System.Net; + +namespace Jpki.Powershell.Runtime.Http +{ + public class JsonResource : RestResourceBase + where TBody : class + { + public override string ExpectedContentType => "application/json"; + + /// + /// Create a new GET request. + /// + public GetRequest Get() + { + return new GetRequest(this); + } + + //--------------------------------------------------------------------- + // Inner classes. + //--------------------------------------------------------------------- + + /// + /// GET request for a JSON payload. + /// + public class GetRequest : RequestBase + { + public GetRequest(JsonResource resource) : base(resource) + { + } + + internal override HttpRequestMessage CreateRequest() + { + return new HttpRequestMessage(HttpMethod.Get, this.Uri); + } + + public async new Task ExecuteAsync( + CancellationToken cancellationToken) + { + using (var response = await base + .ExecuteAsync(cancellationToken) + .ConfigureAwait(false)) + using (var stream = await response.Content + .ReadAsStreamAsync() + .ConfigureAwait(false)) + { + TBody? body = null; + if (response.Content.Headers.ContentLength > 0) + { + body = Json.Deserialize(stream); + } + + return new Response(response.StatusCode, body); + } + } + } + + /// + /// JSON response + /// + public class Response : ResponseBase + { + public Response( + HttpStatusCode statusCode, + TBody? body) : base(statusCode) + { + this.Body = body; + } + + public TBody? Body { get; } + } + } +} diff --git a/sources/Jpki.Powershell/Runtime/Http/RestClient.cs b/sources/Jpki.Powershell/Runtime/Http/RestClient.cs index 5f6a14b..9f74e10 100644 --- a/sources/Jpki.Powershell/Runtime/Http/RestClient.cs +++ b/sources/Jpki.Powershell/Runtime/Http/RestClient.cs @@ -19,12 +19,8 @@ // under the License. // -using Jpki.Powershell.Runtime.Text; using System; -using System.IO; using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; namespace Jpki.Powershell.Runtime.Http { @@ -35,29 +31,12 @@ internal interface IRestClient : IDisposable /// UserAgent UserAgent { get; } - /// - /// Perform a GET request and response as string. - /// - Task GetStringAsync( - Uri url, - CancellationToken cancellationToken); - - /// - /// Perform a GET request and deserialize the JSON response. - /// - Task GetJsonAsync( - Uri url, - CancellationToken cancellationToken) - where TModel : class; + TResource Resource(Uri url) + where TResource : RestResourceBase, new(); } internal class RestClient : IRestClient { - // - // Use a custom timeout (default is 100sec). - // - private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(10); - // // Underlying HTTP client. We keep using the same client so // that we can benefit from the underlying connection pool. @@ -71,7 +50,7 @@ internal RestClient( this.client = client.ExpectNotNull(nameof(client)); this.UserAgent = userAgent.ExpectNotNull(nameof(userAgent)); - this.client.Timeout = DefaultTimeout; + client.DefaultRequestHeaders.UserAgent.ParseAdd(this.UserAgent.ToHeaderValue()); } public RestClient() @@ -81,85 +60,18 @@ public RestClient() { } - private async Task SendAsync( - HttpRequestMessage request, - Func unmarshalFunc, - CancellationToken cancellationToken) - { - using (var client = new HttpClient()) - { - if (this.UserAgent != null) - { - request.Headers.UserAgent.ParseAdd(this.UserAgent.ToHeaderValue()); - } - - using (var response = await client.SendAsync( - request, - HttpCompletionOption.ResponseHeadersRead, - cancellationToken).ConfigureAwait(false)) - { - response.EnsureSuccessStatusCode(); - - var stream = await response.Content - .ReadAsStreamAsync() - .ConfigureAwait(false); - - return unmarshalFunc(response, stream); - } - } - } - - private async Task GetAsync( - Uri url, - Func unmarshalFunc, - CancellationToken cancellationToken) - { - using (var request = new HttpRequestMessage(HttpMethod.Get, url)) - { - return await SendAsync(request, unmarshalFunc, cancellationToken); - } - } - //--------------------------------------------------------------------- // IRestClient. //--------------------------------------------------------------------- public UserAgent UserAgent { get; } - public async Task GetJsonAsync( - Uri url, - CancellationToken cancellationToken) - where TModel : class - { - return await GetAsync( - url, - (response, stream) => - { - if (response.Content.Headers.ContentLength == 0) - { - return null; - } - - return Json.Deserialize(stream); - }, - cancellationToken); - } - - - public async Task GetStringAsync( - Uri url, - CancellationToken cancellationToken) + public TResource Resource(Uri url) + where TResource : RestResourceBase, new() { - return await GetAsync( - url, - (_, stream) => - { - using (var reader = new StreamReader(stream)) - { - return reader.ReadToEnd(); - } - }, - cancellationToken); + var resource = new TResource(); + resource.Initialize(this.client, url); + return resource; } //--------------------------------------------------------------------- diff --git a/sources/Jpki.Powershell/Runtime/Http/RestResourceBase.cs b/sources/Jpki.Powershell/Runtime/Http/RestResourceBase.cs new file mode 100644 index 0000000..ad1cb03 --- /dev/null +++ b/sources/Jpki.Powershell/Runtime/Http/RestResourceBase.cs @@ -0,0 +1,141 @@ +// +// Copyright 2024 Johannes Passing +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// + +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace Jpki.Powershell.Runtime.Http +{ + public abstract class RestResourceBase + { + private Uri? baseUri; + private HttpClient? client; + + public abstract string ExpectedContentType { get; } + + public Uri BaseUri + { + get => this.baseUri ?? throw new InvalidOperationException( + "Resource has not been initialized"); + private set => this.baseUri = value; + } + + public HttpClient Client + { + get => this.client ?? throw new InvalidOperationException( + "Resource has not been initialized"); + private set => this.client = value; + } + + internal void Initialize(HttpClient client, Uri uri) + { + this.Client = client; + this.BaseUri = uri; + } + + /// + /// Base class for REST requests. + /// + public abstract class RequestBase + { + private readonly RestResourceBase resource; + + protected RequestBase(RestResourceBase resource) + { + this.resource = resource; + this.Uri = resource.BaseUri; + } + + // + // Request timeout. + // + public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(10); + + /// + /// Request URI. + /// + public Uri Uri { get; set; } + + /// + /// Initialze a HTTP request message. + /// + /// + internal abstract HttpRequestMessage CreateRequest(); + + /// + /// Send the request. + /// + internal async Task ExecuteAsync( + CancellationToken cancellationToken) + { + using (var request = CreateRequest()) + { + var response = await this.resource + .Client + .SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken) + .ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + + var contentType = response.Content.Headers.ContentType?.MediaType; + if (contentType != null && + contentType != this.resource.ExpectedContentType) + { + throw new UnexpectedContentTypeException( + $"Received unexpected content type '{contentType}' " + + $"(expected: '{this.resource.ExpectedContentType}')"); + } + + return response; + } + } + } + + /// + /// Base class for REST responses. + /// + public abstract class ResponseBase + { + /// + /// HTTP status code. + /// + public HttpStatusCode StatusCode { get; } + + protected ResponseBase(HttpStatusCode statusCode) + { + this.StatusCode = statusCode; + } + } + } + + public class UnexpectedContentTypeException : HttpRequestException + { + internal UnexpectedContentTypeException(string message) : base(message) + { + } + } +} diff --git a/sources/Jpki.Powershell/Runtime/Http/TextResource.cs b/sources/Jpki.Powershell/Runtime/Http/TextResource.cs new file mode 100644 index 0000000..b7896cf --- /dev/null +++ b/sources/Jpki.Powershell/Runtime/Http/TextResource.cs @@ -0,0 +1,90 @@ +// +// Copyright 2024 Johannes Passing +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// + +using System.Net.Http; +using System.Net; +using System.Threading; +using System.Threading.Tasks; + +namespace Jpki.Powershell.Runtime.Http +{ + public class TextResource : RestResourceBase + { + public override string ExpectedContentType => "text/plain"; + + /// + /// Create a new GET request. + /// + public GetRequest Get() + { + return new GetRequest(this); + } + + //--------------------------------------------------------------------- + // Inner classes. + //--------------------------------------------------------------------- + + /// + /// GET request for a payload. + /// + public class GetRequest : RequestBase + { + public GetRequest(TextResource resource) : base(resource) + { + } + + internal override HttpRequestMessage CreateRequest() + { + return new HttpRequestMessage(HttpMethod.Get, this.Uri); + } + + public new async Task ExecuteAsync( + CancellationToken cancellationToken) + { + using (var response = await base + .ExecuteAsync(cancellationToken) + .ConfigureAwait(false)) + { + var body = await response.Content + .ReadAsStringAsync() + .ConfigureAwait(false); + + return new Response(response.StatusCode, body); + } + } + } + + /// + /// Text response + /// + public class Response : ResponseBase + { + public Response( + HttpStatusCode statusCode, + string? body) : base(statusCode) + { + this.Body = body; + } + + public string? Body { get; } + } + } +} diff --git a/sources/Jpki.Powershell/Runtime/LinqExtensions.cs b/sources/Jpki.Powershell/Runtime/LinqExtensions.cs new file mode 100644 index 0000000..261e2e7 --- /dev/null +++ b/sources/Jpki.Powershell/Runtime/LinqExtensions.cs @@ -0,0 +1,35 @@ +// +// Copyright 2024 Johannes Passing +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// + +using System.Collections.Generic; +using System.Linq; + +namespace Jpki.Powershell.Runtime +{ + internal static class LinqExtensions + { + public static IEnumerable EnsureNotNull( + this IEnumerable? e) + { + return e ?? Enumerable.Empty(); + } + } +} diff --git a/sources/Jpki.Powershell/Security/WebAuthn/GetWebAuthnAttestationMetadata.cs b/sources/Jpki.Powershell/Security/WebAuthn/GetWebAuthnAttestationMetadata.cs new file mode 100644 index 0000000..4349268 --- /dev/null +++ b/sources/Jpki.Powershell/Security/WebAuthn/GetWebAuthnAttestationMetadata.cs @@ -0,0 +1,97 @@ +// +// Copyright 2024 Johannes Passing +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// + +using Jpki.Powershell.Runtime; +using Jpki.Powershell.Runtime.Http; +using Jpki.Security.WebAuthn.Metadata; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Management.Automation; +using System.Threading; +using System.Threading.Tasks; + +namespace Jpki.Powershell.Security.WebAuthn +{ + [Cmdlet(VerbsCommon.Get, "WebAuthnAttestationMetadata")] + public class GetWebAuthnAttestationMetadata + : AsyncCmdletBase> // TODO: test + { + private const string FidoParameterSet = null; + private const string U2fParameterSet = null; + + //--------------------------------------------------------------------- + // Fido parameter set. + //--------------------------------------------------------------------- + + [Parameter(Mandatory = false, ParameterSetName = nameof(FidoParameterSet))] + public string? Aaguid { get; set; } + + //--------------------------------------------------------------------- + // Detailed parameter set. + //--------------------------------------------------------------------- + + [Parameter(Mandatory = false, ParameterSetName = nameof(U2fParameterSet))] + public string? Aaid { get; set; } + + //--------------------------------------------------------------------- + // Overrides. + //--------------------------------------------------------------------- + + protected override async Task> ProcessRecordAsync( + CancellationToken cancellationToken) + { + var payload = await MdsMetadataResource + .DownloadAsync(cancellationToken) + .ConfigureAwait(false); + + return payload + .Entries + .EnsureNotNull() + .Where(e => this.Aaguid == null || this.Aaguid == e.AaguidString) + .Where(e => this.Aaid == null || this.Aaguid == e.Aaid); + } + + private class MdsMetadataResource : TextResource + { + /// + /// Metadata URL as published on https://fidoalliance.org/metadata/. + /// + public static readonly Uri Url = new Uri("https://mds3.fidoalliance.org/"); + + public override string ExpectedContentType => "application/octet-stream"; + + public static async Task DownloadAsync(CancellationToken cancellationToken) + { + using (var restClient = new RestClient()) + { + var metadataJwt = await restClient + .Resource(Url) + .Get() + .ExecuteAsync(cancellationToken) + .ConfigureAwait(false); + + return MetadataBlob.ParseUntrusted(metadataJwt.Body); + } + } + } + } +} diff --git a/sources/Jpki.Powershell/Security/WebAuthn/Metadata/MetadataBlob.cs b/sources/Jpki.Powershell/Security/WebAuthn/Metadata/MetadataBlob.cs new file mode 100644 index 0000000..6af60dc --- /dev/null +++ b/sources/Jpki.Powershell/Security/WebAuthn/Metadata/MetadataBlob.cs @@ -0,0 +1,274 @@ +// +// Copyright 2024 Johannes Passing +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// + +using System.Collections.Generic; +using System; +using System.ComponentModel; +using System.Linq; +using Jpki.Powershell.Runtime.Text; +using System.Text; +using System.Security.Cryptography.X509Certificates; + + + + + + +#if NETFRAMEWORK +using JsonPropertyName = Newtonsoft.Json.JsonPropertyAttribute; +using JsonConstructorAttribute = Newtonsoft.Json.JsonConstructorAttribute; +#else +using JsonPropertyName = System.Text.Json.Serialization.JsonPropertyNameAttribute; +using JsonConstructorAttribute = System.Text.Json.Serialization.JsonConstructorAttribute; +#endif + +namespace Jpki.Security.WebAuthn.Metadata +{ + /// + /// Describes the status of an authenticator model as identified by its + /// AAID/AAGUID or attestationCertificateKeyIdentifiers and potentially + /// some additional information + /// + public enum AuthenticatorStatus + { + Unknown = 0, + + NOT_FIDO_CERTIFIED, + FIDO_CERTIFIED, + USER_VERIFICATION_BYPASS, + ATTESTATION_KEY_COMPROMISE, + USER_KEY_REMOTE_COMPROMISE, + USER_KEY_PHYSICAL_COMPROMISE, + UPDATE_AVAILABLE, + REVOKED, + SELF_ASSERTION_SUBMITTED, + FIDO_CERTIFIED_L1, + FIDO_CERTIFIED_L1plus, + FIDO_CERTIFIED_L2, + FIDO_CERTIFIED_L2plus, + FIDO_CERTIFIED_L3, + FIDO_CERTIFIED_L3plus, + }; + + /// + /// Represents the MetadataBLOBPayload. + /// + public class MetadataBlob + { + /// + /// Parse a JWT-encoded blob without verifying the JWT. + /// + public static MetadataBlob ParseUntrusted(string? jwt) + { + var encodedBody = jwt + .ExpectNotNull(nameof(jwt)) + .Split('.') + .Skip(1) + .FirstOrDefault() + .ExpectNotNull("JWT body"); + + var body = Encoding.UTF8.GetString(Base64UrlEncoding.Decode(encodedBody)); + var payload = Json.Deserialize(body); + + return payload ?? throw new InvalidMetadataException( + "The metadata blob does not contain a valid MetadataBLOBPayload"); + } + + /// + /// Indication of the acceptance of the relevant legal agreement + /// for using the MDS. + /// + [JsonPropertyName("legalHeader")] + public string? LegalHeader { get; set; } + + /// + /// The serial number of this UAF Metadata BLOB Payload. + /// + [JsonPropertyName("no")] + public int No { get; set; } + + /// + /// Date when the next update will be provided at latest. + /// + [JsonPropertyName("nextUpdate")] + public DateTimeOffset? NextUpdate { get; set; } + + /// + /// List of zero or more MetadataBLOBPayloadEntry objects. + /// + [JsonPropertyName("entries")] + public IReadOnlyList? Entries { get; set; } + + //--------------------------------------------------------------------- + // Inner classes. + //--------------------------------------------------------------------- + + /// + /// Status reports applicable to this authenticator. + /// + public class StatusReport + { + [JsonPropertyName("status")] + [EditorBrowsable(EditorBrowsableState.Never)] + public string? StatusString { get; set; } + + /// + /// Status of the authenticator. + /// + public AuthenticatorStatus Status + { + get => string.IsNullOrEmpty(this.StatusString) + ? AuthenticatorStatus.Unknown + : (AuthenticatorStatus)Enum.Parse( + typeof(AuthenticatorStatus), + this.StatusString); + } + + /// + /// Date since when the status code was set, if applicable. If no date + /// is given, the status is assumed to be effective while present. + /// + [JsonPropertyName("effectiveDate")] + public DateTimeOffset? EffectiveDate { get; set; } + + /// + /// Version this status report relates to. + /// + [JsonPropertyName("authenticatorVersion")] + public long? AuthenticatorVersion { get; set; } + + [JsonPropertyName("certificate")] + [EditorBrowsable(EditorBrowsableState.Never)] + public string? CertificateString { get; set; } + + /// + /// Base64-encoded PKIX certificate value related to the current status, if applicable. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public X509Certificate2? Certificate + { + get => this.CertificateString != null + ? new X509Certificate2(Convert.FromBase64String(this.CertificateString)) + : null; + } + + /// + /// HTTPS URL where additional information may be found related + /// to the current status, if applicable. + /// + [JsonPropertyName("url")] + public string? Url { get; set; } + + /// + /// Describes the externally visible aspects of the Authenticator + /// certification evaluation. + /// + [JsonPropertyName("certificationDescriptor")] + public string? CertificationDescriptor { get; set; } + + /// + /// The unique identifier for the issued Certification. + /// + [JsonPropertyName("certificateNumber")] + public string? CertificateNumber { get; set; } + + /// + /// The version of the Authenticator Certification Policy + /// the implementation is certified to, e.g. "1.0.0". + /// + [JsonPropertyName("certificationPolicyVersion")] + public string? CertificationPolicyVersion { get; set; } + + /// + /// The document version of the Authenticator Security Requirements + /// the implementation is certified to, e.g. "1.2.0". + /// + [JsonPropertyName("certificationRequirementsVersion")] + public string? CertificationRequirementsVersion { get; set; } + } + + + /// + /// Represents the MetadataBLOBPayloadEntry. + /// + public class Entry + { + /// + /// The AAID of the authenticator this metadata BLOB payload entry relates to. + /// See [UAFProtocol] for the definition of the AAID structure. + /// + /// + [JsonPropertyName("aaid")] + public string? Aaid { get; set; } + + /// + /// The Authenticator Attestation GUID. See [FIDOKeyAttestation] for the + /// definition of the AAGUID structure. + /// + + [JsonPropertyName("aaguid")] + [EditorBrowsable(EditorBrowsableState.Never)] + public string? AaguidString { get; set; } + + /// + /// The Authenticator Attestation GUID. See [FIDOKeyAttestation] for the + /// definition of the AAGUID structure. + /// + public Guid? Aaguid + { + get => string.IsNullOrEmpty(this.AaguidString) + ? (Guid?)null + : Guid.Parse(this.AaguidString); + } + + /// + /// A list of the attestation certificate public key identifiers. + /// + [JsonPropertyName("attestationCertificateKeyIdentifiers")] + public IReadOnlyList? AttestationCertificateKeyIdentifiers { get; set; } + + /// + /// The metadata statement as defined in [FIDOMetadataStatement]. + /// + [JsonPropertyName("metadataStatement")] + public MetadataStatement? MetadataStatement { get; set; } + + /// + /// Status reports applicable to this authenticator. + /// + [JsonPropertyName("statusReports")] + public IReadOnlyList? StatusReports { get; set; } + + /// + /// Date since when the status report array was set to the current value. + /// + [JsonPropertyName("timeOfLastStatusChange")] + public DateTimeOffset? TimeOfLastStatusChange { get; set; } + } + } + + public class InvalidMetadataException : Exception + { + internal InvalidMetadataException(string message) : base(message) + { + } + } +} diff --git a/sources/Jpki.Powershell/Security/WebAuthn/Metadata/MetadataStatement.cs b/sources/Jpki.Powershell/Security/WebAuthn/Metadata/MetadataStatement.cs new file mode 100644 index 0000000..ed1f354 --- /dev/null +++ b/sources/Jpki.Powershell/Security/WebAuthn/Metadata/MetadataStatement.cs @@ -0,0 +1,511 @@ +// +// Copyright 2024 Johannes Passing +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// + +using System.Collections.Generic; +using System; +using System.ComponentModel; +using System.Security.Cryptography.X509Certificates; +using System.Linq; + + + + +#if NETFRAMEWORK +using JsonPropertyName = Newtonsoft.Json.JsonPropertyAttribute; +using JsonConstructorAttribute = Newtonsoft.Json.JsonConstructorAttribute; +#else +using JsonPropertyName = System.Text.Json.Serialization.JsonPropertyNameAttribute; +using JsonConstructorAttribute = System.Text.Json.Serialization.JsonConstructorAttribute; +#endif + +namespace Jpki.Security.WebAuthn.Metadata +{ + /// + /// The metadata statement as defined in [FIDOMetadataStatement]. + /// + public class MetadataStatement + { + /// + /// The legalHeader, which must be in each Metadata Statement, is an indication of + /// the acceptance of the relevant legal agreement for using the MDS. + /// + [JsonPropertyName("legalHeader")] + public string? LegalHeader { get; set; } + + /// + /// The Authenticator Attestation ID. See [UAFProtocol] for the definition of the + /// AAID structure. + /// + [JsonPropertyName("aaid")] + public string? Aaid { get; set; } + + [JsonPropertyName("aaguid")] + [EditorBrowsable(EditorBrowsableState.Never)] + public string? AaguidString { get; set; } + + /// + /// The Authenticator Attestation GUID. See [FIDOKeyAttestation] for the + /// definition of the AAGUID structure. + /// + public Guid? Aaguid + { + get => string.IsNullOrEmpty(this.AaguidString) + ? (Guid?)null + : Guid.Parse(this.AaguidString); + } + + /// + /// A list of the attestation certificate public key identifiers. + /// + [JsonPropertyName("attestationCertificateKeyIdentifiers")] + public IReadOnlyList? AttestationCertificateKeyIdentifiers { get; set; } + + /// + /// A human-readable, short description of the authenticator, in English. + /// + [JsonPropertyName("description")] + public string? Description { get; set; } + + /// + /// A list of human-readable short descriptions of the authenticator + /// in different languages, keyed by the language code. For example: + /// + /// { + /// "ru-RU": "Пример U2F аутентификатора от FIDO Alliance", + /// "fr-FR": "Exemple U2F authenticator de FIDO Alliance" + /// } + /// + [JsonPropertyName("alternativeDescriptions")] + public IDictionary? AlternativeDescriptions { get; set; } + + /// + /// Earliest (i.e. lowest) trustworthy authenticatorVersion meeting the + /// requirements specified in this metadata statement. + /// + [JsonPropertyName("authenticatorVersion")] + public int AuthenticatorVersion { get; set; } + + /// + /// The FIDO protocol family. The values "uaf", "u2f", and "fido2" are supported. + /// + [JsonPropertyName("protocolFamily")] + public string? ProtocolFamily { get; set; } + + /// + /// The Metadata Schema version. + /// + [JsonPropertyName("schema")] + public int Schema { get; set; } + + /// + /// The FIDO unified protocol version(s) (related to the specific protocol + /// family) supported by this authenticator. + /// + [JsonPropertyName("upv")] + public IReadOnlyList? Upv { get; set; } + + /// + /// The list of authentication algorithms supported by the authenticator. + /// + [JsonPropertyName("authenticationAlgorithms")] + public IReadOnlyList? AuthenticationAlgorithms { get; set; } + + /// + /// The list of public key formats supported by the authenticator during + /// registration operations. + /// + [JsonPropertyName("publicKeyAlgAndEncodings")] + public IReadOnlyList? PublicKeyAlgAndEncodings { get; set; } + + /// + /// Complete list of the supported ATTESTATION_ constant case-sensitive + /// string names. + /// + [JsonPropertyName("attestationTypes")] + public IReadOnlyList? AttestationTypes { get; set; } + + /// + /// A list of alternative VerificationMethodANDCombinations. + /// + /// userVerificationDetails is a two dimensional array, that + /// informs RP what VerificationMethodANDCombinations user may be + /// required to perform in order to pass user verification, e.g + /// User need to pass fingerprint, or faceprint, or password and + /// palm print, etc. + /// + [JsonPropertyName("userVerificationDetails")] + public IReadOnlyList>? UserVerificationDetails { get; set; } + + /// + /// The list of key protection types supported by the authenticator. + /// + [JsonPropertyName("keyProtection")] + public IReadOnlyList? KeyProtection { get; set; } + + /// + /// This entry is set to true, if the Uauth private key is restricted by + /// the authenticator to only sign valid FIDO signature assertions. + /// + [JsonPropertyName("isKeyRestricted")] + public bool? IsKeyRestricted { get; set; } = true; + + /// + /// This entry is set to true, if Uauth key usage always requires a fresh user verification. + /// + [JsonPropertyName("isFreshUserVerificationRequired")] + public bool? IsFreshUserVerificationRequired { get; set; } = true; + + /// + /// The list of matcher protections supported by the authenticator. + /// + [JsonPropertyName("matcherProtection")] + public IReadOnlyList? MatcherProtection { get; set; } + + /// + /// The authenticator’s overall claimed cryptographic strength in bits + /// (sometimes also called security strength or security level). + /// + [JsonPropertyName("cryptoStrength")] + public int CryptoStrength { get; set; } + + /// + /// The list of supported attachment hints describing the method(s) + /// by which the authenticator communicates with the FIDO user device. + /// + [JsonPropertyName("attachmentHint")] + public IReadOnlyList? AttachmentHint { get; set; } + + /// + /// The list of supported transaction confirmation display capabilities. + /// + [JsonPropertyName("tcDisplay")] + public IReadOnlyList? TcDisplay { get; set; } + + /// + /// Supported MIME content type [RFC2049] for the transaction + /// confirmation display, such as text/plain or image/png. + /// + [JsonPropertyName("tcDisplayContentType")] + public string? TcDisplayContentType { get; set; } + + /// + /// A list of alternative DisplayPNGCharacteristicsDescriptor. + /// + [JsonPropertyName("tcDisplayPNGCharacteristics")] + public IReadOnlyList? TcDisplayPNGCharacteristics { get; set; } + + [JsonPropertyName("attestationRootCertificates")] + [EditorBrowsable(EditorBrowsableState.Never)] + public IReadOnlyList? AttestationRootCertificateStrings { get; set; } + + /// + /// List of attestation trust anchors for the batch chain + /// in the authenticator attestation. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public IReadOnlyList AttestationRootCertificates + { + get => this.AttestationRootCertificateStrings + .EnsureNotNull() + .Select(c => new X509Certificate2(Convert.FromBase64String(c))) + .ToList(); + } + + /// + /// A data: url [RFC2397] encoded [PNG] icon for the Authenticator. + /// + [JsonPropertyName("icon")] + public string? Icon { get; set; } + + /// + /// List of extensions supported by the authenticator. + /// + [JsonPropertyName("supportedExtensions")] + public IReadOnlyList? SupportedExtensions { get; set; } + + /// + /// Describes supported versions, extensions, AAGUID of the device and its capabilities. + /// + [JsonPropertyName("authenticatorGetInfo")] + public AuthenticatorInfo? AuthenticatorGetInfo { get; set; } + + //--------------------------------------------------------------------- + // Innner classes. + //--------------------------------------------------------------------- + + /// + /// A descriptor for a specific base user verification + /// method as implemented by the authenticator. + /// + public class UserVerificationDescriptor + { + public UserVerificationDescriptor() + { + } + + internal UserVerificationDescriptor(string userVerificationMethod) + { + this.UserVerificationMethod = userVerificationMethod; + } + + [JsonPropertyName("userVerificationMethod")] + public string? UserVerificationMethod { get; set; } + + [JsonPropertyName("caDesc")] + public CodeAccuracyDescriptor? CodeAccuracy { get; set; } + + public override string ToString() + { + return this.UserVerificationMethod ?? "(null)"; + } + + public override bool Equals(object? obj) + { + return obj is UserVerificationDescriptor other && + Equals(this.UserVerificationMethod, other.UserVerificationMethod); + } + + public override int GetHashCode() + { + return this.UserVerificationMethod?.GetHashCode() ?? 0; + } + } + + /// + /// Describes the relevant accuracy/complexity aspects of passcode user verification methods. + /// + public class CodeAccuracyDescriptor + { + [JsonPropertyName("base")] + public int Base { get; set; } + + [JsonPropertyName("minLength")] + public int MinLength { get; set; } + + [JsonPropertyName("maxRetries")] + public int MaxRetries { get; set; } + + [JsonPropertyName("blockSlowdown")] + public int BlockSlowdown { get; set; } + } + + /// + /// The unified protocol version is determined as follows: + /// + /// - in the case of FIDO UAF, use the upv value as specified in the + /// respective "OperationHeader" field, see[UAFProtocol]. + /// + /// - in the case of U2F, use + /// + /// major version 1, minor version 0 for U2F v1.0 + /// major version 1, minor version 1 for U2F v1.1 + /// major version 1, minor version 2 for U2F v1.2 also known as CTAP1 + /// + /// - in the case of FIDO2/CTAP2, use + /// + /// major version 1, minor version 0 for CTAP 2.0 + /// major version 1, minor version 1 for CTAP 2.1 + /// + public class UnifiedProtocolVersion + { + [JsonPropertyName("major")] + public int? Major { get; set; } + + [JsonPropertyName("minor")] + public int? Minor { get; set; } + + public override string ToString() + { + return $"{this.Major}.{this.Minor}"; + } + } + + /// + /// Describes supported versions, extensions, AAGUID of the + /// device and its capabilities. + /// + /// The information is the same reported by an authenticator + /// when invoking the 'authenticatorGetInfo' method, see[FIDOCTAP]. + /// + public class AuthenticatorInfo + { + /// + /// List of supported versions. Supported versions are: + /// + /// - "FIDO_2_0" for CTAP2/FIDO2/Web Authentication authenticators + /// - "U2F_V2" for CTAP1/U2F authenticators. + /// + [JsonPropertyName("versions")] + public IReadOnlyList? Versions { get; set; } + + /// + /// List of supported extensions. + /// + [JsonPropertyName("extensions")] + public IReadOnlyList? Extensions { get; set; } + + [JsonPropertyName("aaguid")] + [EditorBrowsable(EditorBrowsableState.Never)] + public string? AaguidString { get; set; } + + /// + /// The claimed AAGUID. + /// + public Guid? Aaguid + { + get => string.IsNullOrEmpty(this.AaguidString) + ? (Guid?)null + : Guid.Parse(this.AaguidString); + } + + /// + /// List of supported options. + /// + + [JsonPropertyName("options")] + public IDictionary? Options { get; set; } + + /// + /// Maximum message size supported by the authenticator. + /// + + [JsonPropertyName("maxMsgSize")] + public int MaxMsgSize { get; set; } + + /// + /// List of supported PIN Protocol versions. + /// + + [JsonPropertyName("pinUvAuthProtocols")] + public IReadOnlyList? PinUvAuthProtocols { get; set; } + } + + /// + /// This descriptor contains an extension supported by the authenticator. + /// + public class ExtensionDescriptor + { + /// + /// Identifies the extension. + /// + [JsonPropertyName("id")] + public string? Id { get; set; } + + /// + /// Indicates whether unknown extensions must be ignored (false) + /// or must lead to an error (true) when the extension is to be + /// processed by the FIDO Server, FIDO Client, ASM, or FIDO Authenticator. + /// + [JsonPropertyName("fail_if_unknown")] + public bool FailIfUnknown { get; set; } + + /// + /// Contains arbitrary data further describing the extension + /// and/or data needed to correctly process the extension. + /// + [JsonPropertyName("data")] + public string? Data { get; set; } + } + + /// + /// The DisplayPNGCharacteristicsDescriptor describes a PNG image + /// characteristics as defined in the PNG [PNG] spec for IHDR + /// (image header) and PLTE (palette table) + /// + public class DisplayPngCharacteristicsDescriptor + { + /// + /// Image width. + /// + [JsonPropertyName("width")] + public int Width { get; set; } + + /// + /// Image height. + /// + [JsonPropertyName("height")] + public int Height { get; set; } + + /// + /// Bit depth - bits per sample or per palette index. + /// + [JsonPropertyName("bitDepth")] + public int BitDepth { get; set; } + + /// + /// Color type defines the PNG image type. + /// + [JsonPropertyName("colorType")] + public int ColorType { get; set; } + + /// + /// Compression method used to compress the image data. + /// + [JsonPropertyName("compression")] + public int Compression { get; set; } + + /// + /// Filter method is the preprocessing method applied to the + /// image data before compression. + /// + [JsonPropertyName("filter")] + public int Filter { get; set; } + + /// + /// Interlace method is the transmission order of the image data. + /// + [JsonPropertyName("interlace")] + public int Interlace { get; set; } + + /// + /// 1 to 256 palette entries + /// + [JsonPropertyName("plte")] + public IReadOnlyList? Plte { get; set; } + } + + public class RgbPaletteEntry + { + /// + /// Red channel sample value. + /// + [JsonPropertyName("r")] + public int R { get; set; } + + /// + /// Green channel sample value. + /// + [JsonPropertyName("g")] + public int G { get; set; } + + /// + /// Blue channel sample value. + /// + [JsonPropertyName("b")] + public int B { get; set; } + + public override string ToString() + { + return $"{this.R:02X}{this.G:02X}{this.B:02X}"; + } + } + } +} \ No newline at end of file diff --git a/sources/Jpki.Powershell/Security/WebAuthn/NewWebAuthnCredential.cs b/sources/Jpki.Powershell/Security/WebAuthn/NewWebAuthnCredential.cs index f495ab9..7ea1521 100644 --- a/sources/Jpki.Powershell/Security/WebAuthn/NewWebAuthnCredential.cs +++ b/sources/Jpki.Powershell/Security/WebAuthn/NewWebAuthnCredential.cs @@ -93,6 +93,9 @@ public class NewWebAuthnCredential : AsyncCmdletBase [Parameter(Mandatory = false, ParameterSetName = nameof(DetailedParameterSet))] public ResidentKeyRequirement ResidentKey { get; set; } + //--------------------------------------------------------------------- + // Overrides. + //--------------------------------------------------------------------- protected override Task ProcessRecordAsync( CancellationToken cancellationToken) { diff --git a/sources/Jpki.Security.WebAuthn.Test/Security/WebAuthn/TestBinaryFormat.cs b/sources/Jpki.Security.WebAuthn.Test/Security/WebAuthn/TestBinaryFormat.cs index 841e326..befc48c 100644 --- a/sources/Jpki.Security.WebAuthn.Test/Security/WebAuthn/TestBinaryFormat.cs +++ b/sources/Jpki.Security.WebAuthn.Test/Security/WebAuthn/TestBinaryFormat.cs @@ -68,13 +68,32 @@ public void ReadUInt16() [Test] public void ReadGuid() { - var guid = Guid.NewGuid().ToByteArray(); - var bytesRead = BigEndian.ReadGuid(guid, 0, out var output); + var bigEndianGuid = new byte[] { + 0xCC, + 0x95, + 0x44, + 0x2b, + 0x2e, + 0xf1, + 0x5e, + 0x4d, + 0xef, + 0xb2, + 0x70, + 0xef, + 0xb1, + 0x06, + 0xfa, + 0xcb, + 0x4e, + }; + + var bytesRead = BigEndian.ReadGuid(bigEndianGuid, 1, out var output); AssertThat.AreEqual(16, bytesRead); AssertThat.AreEqual( - guid, - output.ToByteArray()); + new Guid("95442b2e-f15e-4def-b270-efb106facb4e"), + output); } } } diff --git a/sources/Jpki.Security.WebAuthn.Test/Security/WebAuthn/TestCredential.cs b/sources/Jpki.Security.WebAuthn.Test/Security/WebAuthn/TestCredential.cs index 2ab0341..ff95c3e 100644 --- a/sources/Jpki.Security.WebAuthn.Test/Security/WebAuthn/TestCredential.cs +++ b/sources/Jpki.Security.WebAuthn.Test/Security/WebAuthn/TestCredential.cs @@ -125,7 +125,7 @@ public void Ctap2CredentialWithAssertionAndUserVerification() credential.Id, credential.AuthenticatorData.AttestedCredentialData!.CredentialId); AssertThat.AreEqual( - "04030201-0605-0807-0102-030405060708", + "01020304-0506-0708-0102-030405060708", credential.AuthenticatorData.AttestedCredentialData.Aaguid.ToString()); credential.Verify(); @@ -185,7 +185,7 @@ public void Ctap2CredentialWithAssertion() credential.Id, credential.AuthenticatorData.AttestedCredentialData!.CredentialId); AssertThat.AreEqual( - "04030201-0605-0807-0102-030405060708", + "01020304-0506-0708-0102-030405060708", credential.AuthenticatorData.AttestedCredentialData.Aaguid.ToString()); credential.Verify(); diff --git a/sources/Jpki.Security.WebAuthn/Format/BigEndian.cs b/sources/Jpki.Security.WebAuthn/Format/BigEndian.cs index 8b90dcc..fa42467 100644 --- a/sources/Jpki.Security.WebAuthn/Format/BigEndian.cs +++ b/sources/Jpki.Security.WebAuthn/Format/BigEndian.cs @@ -65,12 +65,25 @@ internal static uint ReadGuid( uint offset, out Guid guid) { - var guidBytes = new byte[16]; - Array.Copy(data, offset, guidBytes, 0, guidBytes.Length); + // + // NB. Guid assumes little endian, so we can't use the + // existing constructor that takes a byte array. + // + ReadUInt32(data, offset + 0, out var a); + ReadUInt16(data, offset + 4, out var b); + ReadUInt16(data, offset + 6, out var c); - guid = new Guid(guidBytes); - - return (uint)guidBytes.Length; + guid = new Guid( + a, b, c, + data[offset + 8], + data[offset + 9], + data[offset + 10], + data[offset + 11], + data[offset + 12], + data[offset + 13], + data[offset + 14], + data[offset + 15]); + return 16; } } } diff --git a/sources/Jpki.Security.WebAuthn/Security/WebAuthn/AttestationStatement.cs b/sources/Jpki.Security.WebAuthn/Security/WebAuthn/AttestationStatement.cs index e067226..d2c05e3 100644 --- a/sources/Jpki.Security.WebAuthn/Security/WebAuthn/AttestationStatement.cs +++ b/sources/Jpki.Security.WebAuthn/Security/WebAuthn/AttestationStatement.cs @@ -37,7 +37,11 @@ namespace Jpki.Security.WebAuthn /// public class AttestationStatement { - private readonly bool isFidoU2f; + /// + /// Indicates whether the attestation is for a U2F (as opposed to a + /// CTAP2) authenticator. + /// + public bool IsU2f { get; } /// /// Attestation signature that was created using the key of the attesting @@ -46,6 +50,9 @@ public class AttestationStatement /// private readonly byte[] signature; + /// + /// Signature algorithm. + /// public CoseSignatureAlgorithm Algorithm { get; } public ICollection? CertificateChain { get; } @@ -61,7 +68,7 @@ internal AttestationStatement( this.Algorithm = algorithm; this.signature = signature.ExpectNotNull(nameof(signature)); this.CertificateChain = certificateChain; - this.isFidoU2f = isFidoU2f; + this.IsU2f = isFidoU2f; } /// @@ -87,7 +94,7 @@ private bool VerifySignature( // // The signature rules differ between U2F and CTAP2/WebAuthN. // - if (this.isFidoU2f) + if (this.IsU2f) { // // Follow U2F rules, see @@ -193,19 +200,22 @@ internal void Verify( throw new InvalidAttestationException("The signature is invalid"); } - // - // Verify that attestnCert meets the requirements in § 8.2.1 - // Packed Attestation Statement Certificate Requirements. - // - if (!this.Certificate.TryGetExtension(Oids.BasicConstraints, out var basicConstraintsExt)) - { - throw new InvalidAttestationException( - "The cerfificate does not contain a basic constraints extension"); - } - else if (((X509BasicConstraintsExtension)basicConstraintsExt!).CertificateAuthority) + if (!this.IsU2f) { - throw new InvalidAttestationException( - "The cerfificate is a CA certificate"); + // + // Verify that attestnCert meets the requirements in § 8.2.1 + // Packed Attestation Statement Certificate Requirements. + // + if (!this.Certificate.TryGetExtension(Oids.BasicConstraints, out var basicConstraintsExt)) + { + throw new InvalidAttestationException( + "The cerfificate does not contain a basic constraints extension"); + } + else if (((X509BasicConstraintsExtension)basicConstraintsExt!).CertificateAuthority) + { + throw new InvalidAttestationException( + "The cerfificate is a CA certificate"); + } } if (this.Certificate.TryGetExtension(Oids.FidoGenCeAaguid, out var aaguidExt) &&