Azure Key Vault and Certificate - .NET Framework ClientCertificateCredential access to Secrets - c#

I have generated .pfx, .pvk and .cer certification files.
In Azure:
I created a new Vault, let's call it MyVault
In MyVault, I created a Secret called SubscriptionKey
MyVault has a Certificates section to which I've uploaded MyCertificate.cer file.
Confusingly enough, Azure also has a "Azure Active Directory" section where I can also upload Certificates. This is what I understood from researching, to be the place where to upload the certificate, and get the associated clientId and tenantId needed for the ClientCertificateCredential constructor.
Goal: Retrieve the secret value from MyVault using a Certificate and the code:
public static string GetSecretFromAzureKeyVault(string secretName)
{
string vaultUrl = "https://MyVault.vault.azure.net/";
string cerPath = "C:\\Personal\\MyCertificate.cer";
ClientCertificateCredential credential = new(
"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
cerPath
);
SecretClient client = new(new Uri(vaultUrl), credential);
KeyVaultSecret secret = client.GetSecret(secretName);
return secret.Value;
}
When running the code I'm still getting null for the line:
KeyVaultSecret secret = client.GetSecret(secretName);
Any suggestions on what I've done wrong in this flow or regarding the resources?
EDIT:
Error screenshot:

I have followed the below steps and got the secret value
Create an app from AAD and register the app using APP registrations.
Create a keyVault and secret. And use the secret name in the code.
Use the ClientId and TenantId from the App registrations and use it in the code.
Download the .pfx format file and use the certificate in the code.
Use .pfx downloaded path in code
public static string GetSecretFromAzureKeyVault(string secretName)
{
string vaultUrl = "https://keyvault.vault.azure.net/";
string cerPath = "C:\\Tools\\keyvault-keycertificate-20230109.pfx";
ClientCertificateCredential credential =
new ClientCertificateCredential("TenantId", "ClientId", cerPath);
SecretClient client = new SecretClient(new Uri(vaultUrl), credential);
KeyVaultSecret secret = client.GetSecret(secretName);
return secret.Value;
}
You can find the secret value in the below highlighted screen.

Related

Azure Key Vault Add Access Policy with C#

I am trying to retrieve all the Certificates, Keys and Secrets from a Key Vault in order to perform a compliance test of it´s settings. I was able to create a Key Vault Client using Azure Management SDK,
KeyVault Client objKeyVaultClient = new KeyVaultClient(
async (string authority, string resource, string scope) =>
{
...
}
);
and trying to retrieve the certificates / keys / secrets with:
Task<IPage<CertificateItem>> test = objKeyVaultClient.GetCertificatesAsync(<vaultUri>);
However, first I need to set the access policies with List and Get permissions. In PowerShell I achieve this with:
Set-AzKeyVaultAccessPolicy -VaultName <VaultName> -UserPrincipalName <upn> -PermissionsToKeys List,Get
Do you know a way that I can do the same in C#?
If you want to manage Azure key vault access policy with Net, please refer to the following steps
create a service principal (I use Azure CLI to do that)
az login
az account set --subscription "<your subscription id>"
# the sp will have Azure Contributor role
az ad sp create-for-rbac -n "readMetric"
Code
// please install sdk Microsoft.Azure.Management.Fluent
private static String tenantId=""; // sp tenant
private static String clientId = ""; // sp appid
private static String clientKey = "";// sp password
private static String subscriptionId=""; //sp subscription id
var creds= SdkContext.AzureCredentialsFactory.FromServicePrincipal(clientId,clientKey,tenantId,AzureEnvironment.AzureGlobalCloud);
var azure = Microsoft.Azure.Management.Fluent.Azure
.Configure()
.WithLogLevel(HttpLoggingDelegatingHandler.Level.Basic)
.Authenticate(creds)
.WithSubscription(subscriptionId);
var vault = await azure.Vaults.GetByResourceGroupAsync("group name", "vault name");
await vault.Update().DefineAccessPolicy()
.ForUser("userPrincipalName")
.AllowKeyPermissions(KeyPermissions.Get)
.AllowKeyPermissions(KeyPermissions.List)
.Attach()
.ApplyAsync();

Azure Storage Client Side Encryption

I'm trying to test client side encryption with an azure storage account. So far I've created a resource group and put my KeyVault, Registered App on Active Directory and inside my keyVault I've created a secret.
I think im failing to map my secret to my storage account, but I figured that they should work if they are in the same resource group.
$key = "qwertyuiopasdfgh"
$b = [System.Text.Encoding]::UTF8.GetBytes($key)
$enc = [System.Convert]::ToBase64String($b)
$secretvalue = ConvertTo-SecureString $enc -AsPlainText -Force
$secret = Set-AzureKeyVaultSecret -VaultName 'ectotecStorageKeyVault' -Name 'ectotecSecret' -SecretValue $secretvalue -ContentType "application/octet-stream"
The problem is that im getting an invalid secret provided error with the following code:
namespace cifradoApp
{
class Program
{
private async static Task<string> GetToken(string authority, string resource, string scope)
{
var authContext = new AuthenticationContext(authority);
ClientCredential clientCred = new ClientCredential(
ConfigurationManager.AppSettings["clientId"],
ConfigurationManager.AppSettings["clientSecret"]);
AuthenticationResult result = await authContext.AcquireTokenAsync(resource, clientCred);
if (result == null)
throw new InvalidOperationException("Failed to obtain the JWT token");
return result.AccessToken;
}
static void Main(string[] args)
{
// This is standard code to interact with Blob storage.
StorageCredentials creds = new StorageCredentials(
ConfigurationManager.AppSettings["accountName"],
ConfigurationManager.AppSettings["accountKey"]
);
CloudStorageAccount account = new CloudStorageAccount(creds, useHttps: true);
CloudBlobClient client = account.CreateCloudBlobClient();
CloudBlobContainer contain = client.GetContainerReference(ConfigurationManager.AppSettings["container"]);
contain.CreateIfNotExists();
// The Resolver object is used to interact with Key Vault for Azure Storage.
// This is where the GetToken method from above is used.
KeyVaultKeyResolver cloudResolver = new KeyVaultKeyResolver(GetToken);
// Retrieve the key that you created previously.
// The IKey that is returned here is an RsaKey.
// Remember that we used the names contosokeyvault and testrsakey1.
var rsa = cloudResolver.ResolveKeyAsync("https://ectotecstoragekeyvault.vault.azure.net/secrets/ectotecSecret/dee97a40c78a4638bbb3fa0d3e13f75e", CancellationToken.None).GetAwaiter().GetResult();
// Now you simply use the RSA key to encrypt by setting it in the BlobEncryptionPolicy.
BlobEncryptionPolicy policy = new BlobEncryptionPolicy(rsa, null);
BlobRequestOptions options = new BlobRequestOptions() { EncryptionPolicy = policy };
// Reference a block blob.
CloudBlockBlob blob = contain.GetBlockBlobReference("BlobPruebaEncrypted.txt");
// Upload using the UploadFromStream method.
using (var stream = System.IO.File.OpenRead(#"C:\Users\moise\Desktop\ectotec stuff\Visual Studio\azureStorageSample\container\BlobPrueba.txt"))
blob.UploadFromStream(stream, stream.Length, null, options, null);
}
}
}
My app settings seems to be working fine, since i valide before with only my account and key to access the storage account, since I made tests without trying to do client side encryption, everything worked out just fine. The problem comes with the secret it seems.
ERROR WHEN I TRY TO UPLOAD SOMETHING TO MY STORAGE ACCOUNT CONTAINER(BLOB)
AdalException: {"error":"invalid_client","error_description":"AADSTS70002: Error validating credentials. AADSTS50012: Invalid client secret is provided.\r\nTrace ID: 52047a12-b950-4d8a-9206-120e383feb00\r\nCorrelation ID: e2ad8afe-4272-49aa-94c0-5dad435ffc45\r\nTimestamp: 2019-01-02 17:10:32Z","error_codes":[70002,50012],"timestamp":"2019-01-02 17:10:32Z","trace_id":"52047a12-b950-4d8a-9206-120e383feb00","correlation_id":"e2ad8afe-4272-49aa-94c0-5dad435ffc45"}: Unknown error
<appSettings>
<add key="accountName" value="sampleExample"/>
<add key="accountKey" value="KeyForMyApp"/>
<add key="clientId" value="app-id"/>
<add key="clientSecret" value="qwertyuiopasdfgh"/>
<add key="container" value="ectotec-sample2"/>
</appSettings>
I'm trying to replicate the example in this tutorial:
https://learn.microsoft.com/en-us/azure/storage/blobs/storage-encrypt-decrypt-blobs-key-vault
You need to make sure that you have granted your appliation rights to read keys. This is seperate from the RBAC permissions on the Key Vault.
To do this, browse to teh Key Vault in the portal, on the menu on the left you will see a settings section, and under here an item called "access policies", click on this.
You then want to click the "Add New" button. In the window that opens, click on the "Select Principal" section, and then enter in the name or application ID of the application you want to have access. Select the appropriate permissions for keys, secrets or certificates and then click OK.
This will take you back to the list of authorised users, be sure to click save at the top left (it isn't obvious you need to do this), your app should then have access.

How do I connect a server service to Dynamics Online

I am modifying an internal management application to connect to our online hosted Dynamics 2016 instance.
Following some online tutorials, I have been using an OrganizationServiceProxy out of Microsoft.Xrm.Sdk.Client from the SDK.
This seems to need a username and password to connect, which works fine, but I would like to connect in some way that doesn't require a particular user's account details. I don't think the OAuth examples I've seen are suitable, as there is no UI, and no actual person to show an OAuth request to.
public class DynamicsHelper
{
private OrganizationServiceProxy service;
public void Connect(string serviceUri, string username, string password)
{
var credentials = new ClientCredentials();
credentials.UserName.UserName = username;
credentials.UserName.Password = password;
var organizationUri = new Uri(serviceUri);
this.service = new OrganizationServiceProxy(organizationUri, null, credentials, null);
}
}
Is there a way to connect with an application token or API key?
I've found that to do this successfully, you'll need to setup all of the following:
Create an application registration in Azure AD:
grant it API permissions for Dynamics, specifically "Access Dynamics 365 as organization users"
give it a dummy web redirect URI such as http://localhost/auth
generate a client secret and save it for later
Create a user account in Azure AD and give it permissions to Dynamics.
Create an application user record in Dynamics with the same email as the non-interactive user account above.
Authenticate your application using the user account you've created.
For step 4, you'll want to open an new incognito window, construct a url using the following pattern and login using your user account credentials in step 2:
https://login.microsoftonline.com/<your aad tenant id>/oauth2/authorize?client_id=<client id>&response_type=code&redirect_uri=<redirect uri from step 1>&response_mode=query&resource=https://<organization name>.<region>.dynamics.com&state=<random value>
When this is done, you should see that your Dynamics application user has an Application ID and Application ID URI.
Now with your ClientId and ClientSecret, along with a few other organization specific variables, you can authenticate with Azure Active Directory (AAD) to acquire an oauth token and construct an OrganizationWebProxyClient. I've never found a complete code example of doing this, but I have developed the following for my own purposes. Note that the token you acquire has an expiry of 1 hr.
internal class ExampleClientProvider
{
// Relevant nuget packages:
// <package id="Microsoft.CrmSdk.CoreAssemblies" version="9.0.2.9" targetFramework="net472" />
// <package id="Microsoft.IdentityModel.Clients.ActiveDirectory" version="4.5.1" targetFramework="net461" />
// Relevant imports:
// using Microsoft.IdentityModel.Clients.ActiveDirectory;
// using Microsoft.Crm.Sdk.Messages;
// using Microsoft.Xrm.Sdk;
// using Microsoft.Xrm.Sdk.Client;
// using Microsoft.Xrm.Sdk.WebServiceClient;
private const string TenantId = "<your aad tenant id>"; // from your app registration overview "Directory (tenant) ID"
private const string ClientId = "<your client id>"; // from your app registration overview "Application (client) ID"
private const string ClientSecret = "<your client secret>"; // secret generated in step 1
private const string LoginUrl = "https://login.microsoftonline.com"; // aad login url
private const string OrganizationName = "<your organization name>"; // check your dynamics login url, e.g. https://<organization>.<region>.dynamics.com
private const string OrganizationRegion = "<your organization region>"; // might be crm for north america, check your dynamics login url
private string GetServiceUrl()
{
return $"{GetResourceUrl()}/XRMServices/2011/Organization.svc/web";
}
private string GetResourceUrl()
{
return $"https://{OrganizationName}.api.{OrganizationRegion}.dynamics.com";
}
private string GetAuthorityUrl()
{
return $"{LoginUrl}/{TenantId}";
}
public async Task<OrganizationWebProxyClient> CreateClient()
{
var context = new AuthenticationContext(GetAuthorityUrl(), false);
var token = await context.AcquireTokenAsync(GetResourceUrl(), new ClientCredential(ClientId, ClientSecret));
return new OrganizationWebProxyClient(new Uri(GetServiceUrl()), true)
{
HeaderToken = token.AccessToken,
SdkClientVersion = "9.1"
};
}
public async Task<OrganizationServiceContext> CreateContext()
{
var client = await CreateClient();
return new OrganizationServiceContext(client);
}
public async Task TestApiCall()
{
var context = await CreateContext();
// send a test request to verify authentication is working
var response = (WhoAmIResponse) context.Execute(new WhoAmIRequest());
}
}
With Microsoft Dynamics CRM Online or internet facing deployments
When you use the Web API for CRM Online or an on-premises Internet-facing deployment (IFD)
you must use OAuth as described in Connect to Microsoft Dynamics CRM web services using OAuth.
Before you can use OAuth authentication to connect with the CRM web services,
your application must first be registered with Microsoft Azure Active Directory.
Azure Active Directory is used to verify that your application is permitted access to the business data stored in a CRM tenant.
// TODO Substitute your correct CRM root service address,
string resource = "https://mydomain.crm.dynamics.com";
// TODO Substitute your app registration values that can be obtained after you
// register the app in Active Directory on the Microsoft Azure portal.
string clientId = "e5cf0024-a66a-4f16-85ce-99ba97a24bb2";
string redirectUrl = "http://localhost/SdkSample";
// Authenticate the registered application with Azure Active Directory.
AuthenticationContext authContext =
new AuthenticationContext("https://login.windows.net/common", false);
AuthenticationResult result =
authContext.AcquireToken(resource, clientId, new Uri(redirectUrl));
P.S: Concerning your method, it is a best practice to not to store the password as clear text, crypt it, or encrypt the configuration sections for maximum security.
See walkhrough here
Hope this helps :)
If I understand your question correctly, you want to connect to Dynamics 2016 (Dynamics 365) through a Registerd Azure Application with ClientId and Secret, instead of Username and Password. If this is correct, yes this is possible with the OrganizationWebProxyClient . You can even use strongly types assemblies.
var organizationWebProxyClient = new OrganizationWebProxyClient(GetServiceUrl(), true);
organizationWebProxyClient.HeaderToken = authToken.AccessToken;
OrganizationRequest request = new OrganizationRequest()
{
RequestName = "WhoAmI"
};
WhoAmIResponse response = organizationWebProxyClient.Execute(new WhoAmIRequest()) as WhoAmIResponse;
Console.WriteLine(response.UserId);
Contact contact = new Contact();
contact.EMailAddress1 = "jennie.whiten#mycompany.com";
contact.FirstName = "Jennie";
contact.LastName = "White";
contact.Id = Guid.NewGuid();
organizationWebProxyClient.Create(contact);
To get the AccessToken, please refer to the following post Connect to Dynamics CRM WebApi from Console Application.
Replace line 66 (full source code)
authToken = await authContext.AcquireTokenAsync(resourceUrl, clientId, new Uri(redirectUrl), new PlatformParameters(PromptBehavior.Never));
with
authToken = await authContext.AcquireTokenAsync( resourceUrl, new ClientCredential(clientId, secret));
You can also check the following Link Authenticate Azure Function App to connect to Dynamics 365 CRM online that describes how to secure your credentials using the Azure Key Vault.

How to serialize and deserialize a PFX certificate in Azure Key Vault?

I have a bunch of strings and pfx certificates, which I want to store in Azure Key vault, where only allowed users/apps will be able to get them. It is not hard to do store a string as a Secret, but how can I serialize a certificate in such way that I could retrieve it and deserialize as an X509Certificate2 object in C#?
I tried to store it as a key. Here is the Azure powershell code
$securepfxpwd = ConvertTo-SecureString -String 'superSecurePassword' -AsPlainText -Force
$key = Add-AzureKeyVaultKey -VaultName 'UltraVault' -Name 'MyCertificate' -KeyFilePath 'D:\Certificates\BlaBla.pfx' -KeyFilePassword $securepfxpwd
But when I tried to get it with GetKeyAsync method, I couldn't use it.
Here's a PowerShell script for you. Replace the file path, password, vault name, secret name.
$pfxFilePath = 'C:\mycert.pfx'
$pwd = '123'
$flag = [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::Exportable
$collection = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2Collection
$collection.Import($pfxFilePath, $pwd, $flag)
$pkcs12ContentType = [System.Security.Cryptography.X509Certificates.X509ContentType]::Pkcs12
$clearBytes = $collection.Export($pkcs12ContentType)
$fileContentEncoded = [System.Convert]::ToBase64String($clearBytes)
$secret = ConvertTo-SecureString -String $fileContentEncoded -AsPlainText –Force
$secretContentType = 'application/x-pkcs12'
Set-AzureKeyVaultSecret -VaultName 'myVaultName' -Name 'mySecretName' -SecretValue $Secret -ContentType $secretContentType
This is a common question, so we are going to polish this up and release as a helper.
The script above strips the password because there's no value in having a password protected PFX and then storing the password next to it.
The original question asked how to retrieve the stored PFX as an X509Certificate2 object. Using a Base64 process similar to that posted by Sumedh Barde above (which has the advantage of stripping the password), the following code will return a X509 object. In a real application, the KeyVaultClient should be cached if you're retrieving multiple secrets, and the individual secrets should also be cached.
public static async Task<X509Certificate2> GetSecretCertificateAsync(string secretName)
{
string baseUri = #"https://xxxxxxxx.vault.azure.net/secrets/";
var provider = new AzureServiceTokenProvider();
var client = new KeyVaultClient(new KeyVaultClient.AuthenticationCallback(provider.KeyVaultTokenCallback));
var secretBundle = await client.GetSecretAsync($"{baseUri}{secretName}").ConfigureAwait(false);
string pfx = secretBundle.Value;
var bytes = Convert.FromBase64String(pfx);
var coll = new X509Certificate2Collection();
coll.Import(bytes, "certificatePassword", X509KeyStorageFlags.Exportable);
return coll[0];
}
Here is how I solved this. First, convert your PFX file to a base64 string. You can do that with these two simple PowerShell commands:
$fileContentBytes = get-content 'certificate.pfx' -Encoding Byte
[System.Convert]::ToBase64String($fileContentBytes) | Out-File 'certificate_base64.txt'
Create a secret in Azure Key Vault via the Azure Portal. Copy the certificate base64 string that you created previously and paste it in the secret value field in your Azure Key Vault via the Azure Portal. Then simply call the Azure Key Vault from the code to get the base64 string value and convert that to a X509Certificate2:
private async Task<X509Certificate2> GetCertificateAsync()
{
var azureServiceTokenProvider = new AzureServiceTokenProvider();
var keyVaultClient = new KeyVaultClient(new KeyVaultClient.AuthenticationCallback(azureServiceTokenProvider.KeyVaultTokenCallback));
var secret = await keyVaultClient.GetSecretAsync("https://path/to/key/vault").ConfigureAwait(false);
var pfxBase64 = secret.Value;
var bytes = Convert.FromBase64String(pfxBase64);
var coll = new X509Certificate2Collection();
coll.Import(bytes, "certificatePassword", X509KeyStorageFlags.Exportable);
return coll[0];
}
See the following answer, which describes how to do this using the latest .NET Azure SDK client libraries:
How can I create an X509Certificate2 object from an Azure Key Vault KeyBundle
Here is the script for uploading pfx certificate in python using azure cli
azure keyvault secret set --vault-name <Valut name> --secret-name <Secret Name> --value <Content of PFX file>
Getting the content of PFX file in python
fh = open(self.getPfxFilePath(), 'rb')
try:
ba = bytearray(fh.read())
cert_base64_str = base64.b64encode(ba)
password = self.getPassword()
json_blob = {
'data': cert_base64_str,
'dataType': 'pfx',
'password': password
}
blob_data= json.dumps(json_blob)
content_bytes= bytearray(blob_data)
content = base64.b64encode(content_bytes)
return content
finally:
fh.close
fh.close()

Google Analytics Api on Azure

I want to use the google analytics api in my MVC website, im authenticating using the api service account and oauth2 with have no issues on my localhost but as soon as I deploy to Azure i get a 502 error:
"502 - Web server received an invalid response while acting as a
gateway or proxy server. There is a problem with the page you are
looking for, and it cannot be displayed. When the Web server (while
acting as a gateway or proxy) contacted the upstream content server,
it received an invalid response from the content server."
heres my code:
const string ServiceAccountUser = "xxxxxxxxxx-cpla4j8focrebami0l87mbcto09j9j6k#developer.gserviceaccount.com";
AssertionFlowClient client = new AssertionFlowClient(
GoogleAuthenticationServer.Description,
new X509Certificate2(System.Web.Hosting.HostingEnvironment.MapPath("/Areas/Admin/xxxxxxxxxxxxxxxxxx-privatekey.p12"),
"notasecret", X509KeyStorageFlags.Exportable))
{
Scope = AnalyticsService.Scopes.AnalyticsReadonly.GetStringValue(),
ServiceAccountId = ServiceAccountUser //Bug, why does ServiceAccountUser have to be assigned to ServiceAccountId
//,ServiceAccountUser = ServiceAccountUser
};
OAuth2Authenticator<AssertionFlowClient> authenticator = new OAuth2Authenticator<AssertionFlowClient>(client, AssertionFlowClient.GetState);
I cant figure out whats causing it? Am im missing something within Azure?
Thanks for any help.
I also ran into the same issue but passing X509KeyStorageFlags.MachineKeySet into the constructor as well fixed the issue for me.
X509Certificate2 certificate = new X509Certificate2(file, "key", X509KeyStorageFlags.Exportable | X509KeyStorageFlags.MachineKeySet);
After hours of pain on this exact same problem, I found a work around by piecing together various sources of info.
The problem arises from trying to read the p12 file from the Azure web site, i.e. this line in my code fails
var key = new X509Certificate2(keyFile, keyPassword, X509KeyStorageFlags.Exportable);
No idea why, but it works if you split the file into a cer and key.xml file?
Firstly, extract these files, (I just used a console app)
// load pfx/p12 as "exportable"
var p12Cert = new X509Certificate2(#"c:\Temp\xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-privatekey.p12", "notasecret", X509KeyStorageFlags.Exportable);
// export .cer from .pfx/.p12
File.WriteAllBytes(#"C:\Temp\MyCert.cer", p12Cert.Export(X509ContentType.Cert));
// export private key XML
string privateKeyXml = p12Cert.PrivateKey.ToXmlString(true);
File.WriteAllText(#"C:\Temp\PrivateKey.xml", privateKeyXml);
Then copy them to your website then load them in like so
//Store the authentication description
AuthorizationServerDescription desc = GoogleAuthenticationServer.Description;
//Create a certificate object to use when authenticating
var rsaCryptoServiceProvider = new RSACryptoServiceProvider();
rsaCryptoServiceProvider.FromXmlString(File.ReadAllText(keyFile));
var key = new X509Certificate2(certFile) {PrivateKey = rsaCryptoServiceProvider};
//Now, we will log in and authenticate, passing in the description
//and key from above, then setting the accountId and scope
var client = new AssertionFlowClient(desc, key)
{
//cliendId is your SERVICE ACCOUNT Email Address from Google APIs Console
//looks something like 12345-randomstring#developer.gserviceaccount.com
//~IMPORTANT~: this email address has to be added to your Google Analytics profile
// and given Read & Analyze permissions
ServiceAccountId = clientId,
Scope = "https://www.googleapis.com/auth/analytics.readonly"
};
//Finally, complete the authentication process
//NOTE: This is the first change from the update above
var auth = new OAuth2Authenticator<AssertionFlowClient>(client, AssertionFlowClient.GetState);
//First, create a new service object
//NOTE: this is the second change from the update
//above. Thanks to James for pointing this out
var gas = new AnalyticsService(new BaseClientService.Initializer { Authenticator = auth });
This now works for me and I hope it helps you.

Categories

Resources