diff --git a/src/WalletFramework.Core/Cryptography/Abstractions/IKeyStore.cs b/src/WalletFramework.Core/Cryptography/Abstractions/IKeyStore.cs index 7459d6d2..903380ed 100644 --- a/src/WalletFramework.Core/Cryptography/Abstractions/IKeyStore.cs +++ b/src/WalletFramework.Core/Cryptography/Abstractions/IKeyStore.cs @@ -16,45 +16,6 @@ public interface IKeyStore /// A representing the generated key's identifier as a string. Task GenerateKey(string alg = "ES256"); - /// - /// Asynchronously creates a proof of possession for a specific key, based on the provided audience and nonce. - /// - /// The identifier of the key to be used in creating the proof of possession. - /// The intended recipient of the proof. Typically represents the entity that will verify it. - /// - /// A unique token, typically used to prevent replay attacks by ensuring that the proof is only used once. - /// - /// The type of the proof. (For example "openid4vci-proof+jwt") - /// Base64url-encoded hash digest over the Issuer-signed JWT and the selected Disclosures for integrity protection - /// - /// A representing the asynchronous operation. When evaluated, the task's result contains - /// the proof. - /// - Task GenerateKbProofOfPossessionAsync( - KeyId keyId, - string audience, - string nonce, - string type, - string? sdHash = null, - string? clientId = null); - - /// - /// Asynchronously creates a DPoP Proof JWT for a specific key, based on the provided audience, nonce and access token. - /// - /// The identifier of the key to be used in creating the proof of possession. - /// The intended recipient of the proof. Typically represents the entity that will verify it. - /// A unique token, typically used to prevent replay attacks by ensuring that the proof is only used once. - /// The access token, that the DPoP Proof JWT is bound to - /// - /// A representing the asynchronous operation. When evaluated, the task's result contains - /// the DPoP Proof JWT. - /// - Task GenerateDPopProofOfPossessionAsync( - KeyId keyId, - string audience, - string? nonce, - string? accessToken); - /// /// Asynchronously loads a key by its identifier and returns it as a JSON Web Key (JWK) containing the public key /// information. diff --git a/src/WalletFramework.Core/Json/JsonSettings.cs b/src/WalletFramework.Core/Json/JsonSettings.cs new file mode 100644 index 00000000..71f97261 --- /dev/null +++ b/src/WalletFramework.Core/Json/JsonSettings.cs @@ -0,0 +1,11 @@ +using Newtonsoft.Json; + +namespace WalletFramework.Core.Json; + +public static class JsonSettings +{ + public static JsonSerializerSettings SerializerSettings => new() + { + NullValueHandling = NullValueHandling.Ignore + }; +} diff --git a/src/WalletFramework.Oid4Vc/Oid4Vci/AuthFlow/Models/ClientAttestationPopDetails.cs b/src/WalletFramework.Oid4Vc/Oid4Vci/AuthFlow/Models/ClientAttestationPopDetails.cs new file mode 100644 index 00000000..fe5c8597 --- /dev/null +++ b/src/WalletFramework.Oid4Vc/Oid4Vci/AuthFlow/Models/ClientAttestationPopDetails.cs @@ -0,0 +1,30 @@ +using LanguageExt; +using static System.String; + +namespace WalletFramework.Oid4Vc.Oid4Vci.AuthFlow.Models; + +public record ClientAttestationPopDetails +{ + public string Audience { get; } + + public string Issuer { get; } + + public Option Nonce { get; } + + private ClientAttestationPopDetails(string audience, string issuer, string? nonce) + { + Audience = audience; + Issuer = issuer; + Nonce = nonce; + } + + public static ClientAttestationPopDetails CreateClientAttestationPopOptions(string audience, string issuer, string? nonce) + { + if (IsNullOrWhiteSpace(audience) || IsNullOrWhiteSpace(issuer)) + { + throw new ArgumentException("Audience and Issuer must be provided."); + } + + return new ClientAttestationPopDetails(audience, issuer, nonce); + } +} diff --git a/src/WalletFramework.Oid4Vc/Oid4Vci/AuthFlow/Models/CombinedWalletAttestation.cs b/src/WalletFramework.Oid4Vc/Oid4Vci/AuthFlow/Models/CombinedWalletAttestation.cs new file mode 100644 index 00000000..80b2d399 --- /dev/null +++ b/src/WalletFramework.Oid4Vc/Oid4Vci/AuthFlow/Models/CombinedWalletAttestation.cs @@ -0,0 +1,28 @@ +namespace WalletFramework.Oid4Vc.Oid4Vci.AuthFlow.Models; + +public record CombinedWalletAttestation +{ + public WalletInstanceAttestationJwt WalletInstanceAttestationJwt { get; } + + public WalletInstanceAttestationPopJwt WalletInstanceAttestationPopJwt { get; } + + private CombinedWalletAttestation( + WalletInstanceAttestationJwt walletInstanceAttestationJwt, + WalletInstanceAttestationPopJwt walletInstanceAttestationPopJwt) + { + WalletInstanceAttestationJwt = walletInstanceAttestationJwt; + WalletInstanceAttestationPopJwt = walletInstanceAttestationPopJwt; + } + + public static CombinedWalletAttestation Create( + WalletInstanceAttestationJwt walletInstanceAttestationJwt, + WalletInstanceAttestationPopJwt walletInstanceAttestationPopJwt) + => new(walletInstanceAttestationJwt, walletInstanceAttestationPopJwt); +} + +public static class CombinedWalletAttestationExtensions +{ + public static string ToStringRepresentation(this CombinedWalletAttestation combinedWalletAttestation) + => combinedWalletAttestation.WalletInstanceAttestationJwt + "~" + + combinedWalletAttestation.WalletInstanceAttestationPopJwt; +} diff --git a/src/WalletFramework.Oid4Vc/Oid4Vci/AuthFlow/Models/WalletInstanceAttestationJwt.cs b/src/WalletFramework.Oid4Vc/Oid4Vci/AuthFlow/Models/WalletInstanceAttestationJwt.cs new file mode 100644 index 00000000..691f64e6 --- /dev/null +++ b/src/WalletFramework.Oid4Vc/Oid4Vci/AuthFlow/Models/WalletInstanceAttestationJwt.cs @@ -0,0 +1,23 @@ +using WalletFramework.Core.Functional; +using WalletFramework.Core.Functional.Errors; + +namespace WalletFramework.Oid4Vc.Oid4Vci.AuthFlow.Models; + +public struct WalletInstanceAttestationJwt +{ + public string Value { get; } + + public static implicit operator string(WalletInstanceAttestationJwt keyId) => keyId.Value; + + private WalletInstanceAttestationJwt(string value) => Value = value; + + public static Validation ValidWalletInstanceAttestationJwt(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return new StringIsNullOrWhitespaceError(); + } + + return new WalletInstanceAttestationJwt(value); + } +} diff --git a/src/WalletFramework.Oid4Vc/Oid4Vci/AuthFlow/Models/WalletInstanceAttestationPopJwt.cs b/src/WalletFramework.Oid4Vc/Oid4Vci/AuthFlow/Models/WalletInstanceAttestationPopJwt.cs new file mode 100644 index 00000000..1cd8e84e --- /dev/null +++ b/src/WalletFramework.Oid4Vc/Oid4Vci/AuthFlow/Models/WalletInstanceAttestationPopJwt.cs @@ -0,0 +1,12 @@ +namespace WalletFramework.Oid4Vc.Oid4Vci.AuthFlow.Models; + +public struct WalletInstanceAttestationPopJwt +{ + public string Value { get; } + + public static implicit operator string(WalletInstanceAttestationPopJwt keyId) => keyId.Value; + + private WalletInstanceAttestationPopJwt(string value) => Value = value; + + public static WalletInstanceAttestationPopJwt CreateWalletInstanceAttestationPopJwt(string value) => new (value); +} diff --git a/src/WalletFramework.Oid4Vc/Oid4Vci/Authorization/DPop/Implementations/DPopHttpClient.cs b/src/WalletFramework.Oid4Vc/Oid4Vci/Authorization/DPop/Implementations/DPopHttpClient.cs index dfea1ef4..25b2f4d8 100644 --- a/src/WalletFramework.Oid4Vc/Oid4Vci/Authorization/DPop/Implementations/DPopHttpClient.cs +++ b/src/WalletFramework.Oid4Vc/Oid4Vci/Authorization/DPop/Implementations/DPopHttpClient.cs @@ -1,11 +1,17 @@ +using System.Security.Cryptography; +using System.Text; using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.Tokens; +using Newtonsoft.Json; using Newtonsoft.Json.Linq; using WalletFramework.Core.Cryptography.Abstractions; +using WalletFramework.Core.Cryptography.Models; using WalletFramework.Core.Functional; using WalletFramework.Oid4Vc.Oid4Vci.Authorization.DPop.Abstractions; using WalletFramework.Oid4Vc.Oid4Vci.Authorization.DPop.Models; using WalletFramework.Oid4Vc.Oid4Vci.Exceptions; using WalletFramework.Oid4Vc.Oid4Vci.Extensions; +using WalletFramework.SdJwtVc.Services.SdJwtVcHolderService; namespace WalletFramework.Oid4Vc.Oid4Vci.Authorization.DPop.Implementations; @@ -18,14 +24,17 @@ public class DPopHttpClient : IDPopHttpClient public DPopHttpClient( IHttpClientFactory httpClientFactory, IKeyStore keyStore, + ISdJwtSignerService sdJwtSignerService, ILogger logger) { _keyStore = keyStore; + _sdJwtSignerService = sdJwtSignerService; _httpClient = httpClientFactory.CreateClient(); _logger = logger; } private readonly IKeyStore _keyStore; + private readonly ISdJwtSignerService _sdJwtSignerService; private readonly ILogger _logger; private readonly HttpClient _httpClient; @@ -34,7 +43,7 @@ public async Task Post( DPopConfig config, Func getContent) { - var dPop = await _keyStore.GenerateDPopProofOfPossessionAsync( + var dPop = await GenerateDPopHeaderAsync( config.KeyId, config.Audience, config.Nonce.ToNullable(), @@ -53,7 +62,7 @@ public async Task Post( { config = config with { Nonce = new DPopNonce(nonceStr) }; - var newDpop = await _keyStore.GenerateDPopProofOfPossessionAsync( + var newDpop = await GenerateDPopHeaderAsync( config.KeyId, config.Audience, config.Nonce.ToNullable(), @@ -106,4 +115,39 @@ or System.Net.HttpStatusCode.Unauthorized return null; } + + private async Task GenerateDPopHeaderAsync(KeyId keyId, string audience, string? nonce, string? accessToken) + { + var header = new Dictionary + { + { "alg", "ES256" }, + { "typ", "dpop+jwt" } + }; + + var jwkSerialized = await _keyStore.LoadKey(keyId); + var jwkDeserialized = JsonConvert.DeserializeObject(jwkSerialized); + if (jwkDeserialized != null) + { + header["jwk"] = jwkDeserialized; + } + + string? ath = null; + if (!string.IsNullOrEmpty(accessToken)) + { + var sha256 = SHA256.Create(); + ath = Base64UrlEncoder.Encode(sha256.ComputeHash(Encoding.UTF8.GetBytes(accessToken))); + } + + var dPopPayload = new + { + jti = Guid.NewGuid().ToString(), + htm = "POST", + iat = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + htu = new Uri(audience).GetLeftPart(UriPartial.Path), + nonce, + ath + }; + + return await _sdJwtSignerService.CreateSignedJwt(header, dPopPayload, keyId); + } } diff --git a/src/WalletFramework.Oid4Vc/Oid4Vci/CredRequest/Implementations/CredentialRequestService.cs b/src/WalletFramework.Oid4Vc/Oid4Vci/CredRequest/Implementations/CredentialRequestService.cs index aebcdcaa..cb113e21 100644 --- a/src/WalletFramework.Oid4Vc/Oid4Vci/CredRequest/Implementations/CredentialRequestService.cs +++ b/src/WalletFramework.Oid4Vc/Oid4Vci/CredRequest/Implementations/CredentialRequestService.cs @@ -20,6 +20,7 @@ using WalletFramework.Oid4Vc.Oid4Vci.Issuer.Models; using WalletFramework.Oid4Vc.Oid4Vci.CredRequest.Models.SdJwt; using WalletFramework.Oid4Vc.Oid4Vci.CredResponse; +using WalletFramework.SdJwtVc.Services.SdJwtVcHolderService; namespace WalletFramework.Oid4Vc.Oid4Vci.CredRequest.Implementations; @@ -28,15 +29,18 @@ public class CredentialRequestService : ICredentialRequestService public CredentialRequestService( HttpClient httpClient, IDPopHttpClient dPopHttpClient, + ISdJwtSignerService sdJwtSignerService, IKeyStore keyStore) { _dPopHttpClient = dPopHttpClient; - _httpClient = httpClient; + _sdJwtSignerService = sdJwtSignerService; _keyStore = keyStore; + _httpClient = httpClient; } private readonly HttpClient _httpClient; private readonly IDPopHttpClient _dPopHttpClient; + private readonly ISdJwtSignerService _sdJwtSignerService; private readonly IKeyStore _keyStore; private async Task CreateCredentialRequest( @@ -50,7 +54,7 @@ private async Task CreateCredentialRequest( oauthToken => oauthToken.CNonce, dPopToken => dPopToken.Token.CNonce); - var keyBindingJwt = await _keyStore.GenerateKbProofOfPossessionAsync( + var keyBindingJwt = await _sdJwtSignerService.GenerateKbProofOfPossessionAsync( keyId, issuerMetadata.CredentialIssuer.ToString(), cNonce, diff --git a/src/WalletFramework.Oid4Vc/Oid4Vp/PresentationExchange/Services/PexService.cs b/src/WalletFramework.Oid4Vc/Oid4Vp/PresentationExchange/Services/PexService.cs index e3137f49..66d9f7cf 100644 --- a/src/WalletFramework.Oid4Vc/Oid4Vp/PresentationExchange/Services/PexService.cs +++ b/src/WalletFramework.Oid4Vc/Oid4Vp/PresentationExchange/Services/PexService.cs @@ -104,4 +104,4 @@ private static SdJwtDoc _toSdJwtDoc(SdJwtRecord record) { return new SdJwtDoc(record.EncodedIssuerSignedJwt + "~" + string.Join("~", record.Disclosures) + "~"); } -} \ No newline at end of file +} diff --git a/src/WalletFramework.SdJwtVc/ServiceCollectionExtensions.cs b/src/WalletFramework.SdJwtVc/ServiceCollectionExtensions.cs index 7dbfe4fe..69d1cae4 100644 --- a/src/WalletFramework.SdJwtVc/ServiceCollectionExtensions.cs +++ b/src/WalletFramework.SdJwtVc/ServiceCollectionExtensions.cs @@ -11,6 +11,7 @@ public static IServiceCollection AddSdJwtVcServices(this IServiceCollection buil { builder.AddSingleton(); builder.AddSingleton(); + builder.AddSingleton(); return builder; } diff --git a/src/WalletFramework.SdJwtVc/Services/SdJwtVcHolderService/ISdJwtSignerService.cs b/src/WalletFramework.SdJwtVc/Services/SdJwtVcHolderService/ISdJwtSignerService.cs new file mode 100644 index 00000000..96d1176e --- /dev/null +++ b/src/WalletFramework.SdJwtVc/Services/SdJwtVcHolderService/ISdJwtSignerService.cs @@ -0,0 +1,10 @@ +using WalletFramework.Core.Cryptography.Models; + +namespace WalletFramework.SdJwtVc.Services.SdJwtVcHolderService; + +public interface ISdJwtSignerService +{ + Task CreateSignedJwt(object header, object payload, KeyId keyId); + + Task GenerateKbProofOfPossessionAsync(KeyId keyId, string audience, string nonce, string type, string? sdHash, string? clientId); +} diff --git a/src/WalletFramework.SdJwtVc/Services/SdJwtVcHolderService/SdJwtSignerService.cs b/src/WalletFramework.SdJwtVc/Services/SdJwtVcHolderService/SdJwtSignerService.cs new file mode 100644 index 00000000..3d7dbad2 --- /dev/null +++ b/src/WalletFramework.SdJwtVc/Services/SdJwtVcHolderService/SdJwtSignerService.cs @@ -0,0 +1,71 @@ +using System.Text; +using Microsoft.IdentityModel.Tokens; +using Newtonsoft.Json; +using Org.BouncyCastle.Asn1; +using WalletFramework.Core.Cryptography.Abstractions; +using WalletFramework.Core.Cryptography.Models; + +namespace WalletFramework.SdJwtVc.Services.SdJwtVcHolderService; + +public class SdJwtSignerService : ISdJwtSignerService +{ + private readonly IKeyStore _keyStore; + + public SdJwtSignerService(IKeyStore keyStore) => _keyStore = keyStore; + + public async Task GenerateKbProofOfPossessionAsync(KeyId keyId, string audience, string nonce, string type, string? sdHash, string? clientId) + { + var header = new Dictionary + { + { "alg", "ES256" }, + { "typ", type } + }; + + if (string.Equals(type, "openid4vci-proof+jwt", StringComparison.OrdinalIgnoreCase)) + { + var jwkSerialized = await _keyStore.LoadKey(keyId); + var jwkDeserialized = JsonConvert.DeserializeObject(jwkSerialized); + if (jwkDeserialized != null) + { + header["jwk"] = jwkDeserialized; + } + } + + var payload = new + { + aud = audience, + nonce, + iat = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + sd_hash = sdHash, + iss = clientId + }; + + return await CreateSignedJwt(header, payload, keyId); + } + + public async Task CreateSignedJwt(object header, object payload, KeyId keyId) + { + var encodedHeader = Base64UrlEncoder.Encode(JsonConvert.SerializeObject(header)); + var encodedPayload = Base64UrlEncoder.Encode(JsonConvert.SerializeObject(payload)); + + var dataToSign = encodedHeader + "." + encodedPayload; + var signedData = await _keyStore.Sign(keyId, Encoding.UTF8.GetBytes(dataToSign)); + var rawSignature = ConvertDerToRawFormat(signedData); + + var encodedSignature = Base64UrlEncoder.Encode(rawSignature); + return encodedHeader + "." + encodedPayload + "." + encodedSignature; + } + + private static byte[] ConvertDerToRawFormat(byte[]? derSignature) + { + var seq = (Asn1Sequence)Asn1Object.FromByteArray(derSignature); + var r = ((DerInteger)seq[0]).Value; + var s = ((DerInteger)seq[1]).Value; + var rawSignature = new byte[64]; + var rBytes = r.ToByteArrayUnsigned(); + var sBytes = s.ToByteArrayUnsigned(); + Array.Copy(rBytes, Math.Max(0, rBytes.Length - 32), rawSignature, 0, Math.Min(32, rBytes.Length)); + Array.Copy(sBytes, Math.Max(0, sBytes.Length - 32), rawSignature, 32, Math.Min(32, sBytes.Length)); + return rawSignature; + } +} diff --git a/src/WalletFramework.SdJwtVc/Services/SdJwtVcHolderService/SdJwtVcHolderService.cs b/src/WalletFramework.SdJwtVc/Services/SdJwtVcHolderService/SdJwtVcHolderService.cs index 7597ed02..4b1e91cd 100644 --- a/src/WalletFramework.SdJwtVc/Services/SdJwtVcHolderService/SdJwtVcHolderService.cs +++ b/src/WalletFramework.SdJwtVc/Services/SdJwtVcHolderService/SdJwtVcHolderService.cs @@ -4,7 +4,6 @@ using Hyperledger.Indy.WalletApi; using SD_JWT.Models; using SD_JWT.Roles; -using WalletFramework.Core.Cryptography.Abstractions; using WalletFramework.SdJwtVc.Models.Records; namespace WalletFramework.SdJwtVc.Services.SdJwtVcHolderService; @@ -15,21 +14,21 @@ public class SdJwtVcHolderService : ISdJwtVcHolderService /// /// Initializes a new instance of the class. /// - /// The key store responsible for key operations. + /// The service responsible for SD-JWT signature related operations. /// The service responsible for wallet record operations. /// The service responsible for holder operations. public SdJwtVcHolderService( IHolder holder, - IKeyStore keyStore, + ISdJwtSignerService sdJwtSignerService, IWalletRecordService recordService) { _holder = holder; - _keyStore = keyStore; + _sdJwtSignerService = sdJwtSignerService; _recordService = recordService; } private readonly IHolder _holder; - private readonly IKeyStore _keyStore; + private readonly ISdJwtSignerService _sdJwtSignerService; private readonly IWalletRecordService _recordService; /// @@ -56,14 +55,13 @@ public async Task CreatePresentation( && !string.IsNullOrEmpty(nonce) && !string.IsNullOrEmpty(audience)) { - - - var keybindingJwt = await _keyStore.GenerateKbProofOfPossessionAsync( + var keybindingJwt = await _sdJwtSignerService.GenerateKbProofOfPossessionAsync( credential.KeyId, audience, nonce, "kb+jwt", - presentationFormat.ToSdHash()); + presentationFormat.ToSdHash(), + null); return presentationFormat.AddKeyBindingJwt(keybindingJwt); } diff --git a/test/WalletFramework.Integration.Tests/WalletFramework.Integration.Tests/Oid4Vp/Oid4VpClientServiceTests.cs b/test/WalletFramework.Integration.Tests/WalletFramework.Integration.Tests/Oid4Vp/Oid4VpClientServiceTests.cs index f2154033..cc1cc3e5 100644 --- a/test/WalletFramework.Integration.Tests/WalletFramework.Integration.Tests/Oid4Vp/Oid4VpClientServiceTests.cs +++ b/test/WalletFramework.Integration.Tests/WalletFramework.Integration.Tests/Oid4Vp/Oid4VpClientServiceTests.cs @@ -36,7 +36,7 @@ public Oid4VpClientServiceTests() var walletRecordService = new DefaultWalletRecordService(); var pexService = new PexService(); - _sdJwtVcHolderService = new SdJwtVcHolderService(holder, _keyStoreMock.Object, walletRecordService); + _sdJwtVcHolderService = new SdJwtVcHolderService(holder, _sdJwtSignerService.Object, walletRecordService); var oid4VpHaipClient = new Oid4VpHaipClient(_httpClientFactoryMock.Object, pexService); _oid4VpRecordService = new Oid4VpRecordService(walletRecordService); @@ -49,7 +49,7 @@ public Oid4VpClientServiceTests() _oid4VpRecordService ); - _keyStoreMock.Setup(keyStore => + _sdJwtSignerService.Setup(keyStore => keyStore.GenerateKbProofOfPossessionAsync( It.IsAny(), It.IsAny(), @@ -64,7 +64,7 @@ public Oid4VpClientServiceTests() private readonly Mock _httpMessageHandlerMock = new(); private readonly Mock _httpClientFactoryMock = new(); - private readonly Mock _keyStoreMock = new(); + private readonly Mock _sdJwtSignerService = new(); private readonly Mock> _loggerMock = new(); private readonly MockAgentRouter _router = new(); private readonly Oid4VpClientService _oid4VpClientService; diff --git a/test/WalletFramework.SdJwtVc.Tests/SdJwtVcHolderServiceTests.cs b/test/WalletFramework.SdJwtVc.Tests/SdJwtVcHolderServiceTests.cs index abf3b7e2..f9e90e15 100644 --- a/test/WalletFramework.SdJwtVc.Tests/SdJwtVcHolderServiceTests.cs +++ b/test/WalletFramework.SdJwtVc.Tests/SdJwtVcHolderServiceTests.cs @@ -3,7 +3,6 @@ using SD_JWT.Models; using SD_JWT.Roles; using SD_JWT.Roles.Implementation; -using WalletFramework.Core.Cryptography.Abstractions; using WalletFramework.Core.Cryptography.Models; using WalletFramework.SdJwtVc.Models.Credential; using WalletFramework.SdJwtVc.Models.Credential.Attributes; @@ -20,9 +19,9 @@ public SdJwtVcHolderServiceTests() { // Mock with moq IKestore and IWalletRecordService IHolder holder = new Holder(); - IKeyStore keyStoreMock = new Mock().Object; + ISdJwtSignerService sdJwtSignerServiceMock = new Mock().Object; IWalletRecordService walletRecordServiceMock = new Mock().Object; - _service = new SdJwtVcHolderService(holder, keyStoreMock, walletRecordServiceMock); + _service = new SdJwtVcHolderService(holder, sdJwtSignerServiceMock, walletRecordServiceMock); } // https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-08.html#appendix-A.3-4