diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index db64a5c7..4c222a1e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,25 +2,34 @@ name: Release on: push: - tags: [v*] + tags: + - "v*" workflow_dispatch: +permissions: + contents: write + jobs: release: runs-on: windows-latest steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "10.0.102" + - name: Setup Git run: | git config --global url."https://user:${{ secrets.GITHUB_TOKEN }}@github".insteadOf https://github git config --global user.name github-actions - git config --global user.email github-actions@github.com + git config --global user.email github-actions@github.com - name: Run release script shell: PowerShell - run: ./release.ps1 \ No newline at end of file + run: ./release.ps1 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..992c724f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,142 @@ +# AGENTS.md — Azure Key Vault Explorer + +This file provides context for AI coding agents working in this repository. + +## What this project is + +Azure Key Vault Explorer is a Windows desktop application (.NET 10 / WinForms) for browsing, +editing, and managing secrets, keys, and certificates stored in Azure Key Vault. It supports +ClickOnce deployment so end users install and auto-update via a hosted manifest URL. + +This repository is a fork of [reysic/AzureKeyVaultExplorer](https://github.com/reysic/AzureKeyVaultExplorer), +which originated from [microsoft/AzureKeyVaultExplorer](https://github.com/microsoft/AzureKeyVaultExplorer). +Changes developed here are intended to be contributed back upstream via a PR to reysic's fork. + +## Tech stack + +| Layer | Technology | +|---|---| +| UI | Windows Forms (.NET 10) | +| Auth | Microsoft.Identity.Client (MSAL v4) — browser-based OAuth | +| Azure SDK | Microsoft.Azure.Management.KeyVault (ARM), Microsoft.Azure.KeyVault (data plane) | +| Publishing | ClickOnce via MSBuild `/target:publish` with `ClickOnceProfile.pubxml` | +| CI | GitHub Actions (`.github/workflows/release.yml`), triggered by `v*` tags | + +## Repository layout + +``` +AzureKeyVaultExplorer.sln Solution file +Vault/ + Explorer/ Main WinForms app (VaultExplorer.csproj) + Common/ Helpers: ActivationUri, Utils, UxOperation + Config/ VaultConfigurationManager + JSON config templates + Controls/ ListViews, custom WinForms controls + Dialogs/ + Subscriptions/ SubscriptionsManagerDialog — vault picker via ARM + Secrets/ SecretDialog + Certificates/ CertificateDialog + Settings/ SettingsDialog + Model/ Domain objects: PropObjects, ContentTypes, Tags, Aliases + Globals.cs All global URL constants (GitHub, ClickOnce install URL) + MainForm.cs Main window; hosts vault dropdown and secret list + Program.cs Entry point; handles ClickOnce activation URI + Library/ Vault access abstractions (VaultAccess*, VaultConfig, etc.) + Core/ Utilities shared across Library and Explorer + ClearClipboard/ Companion exe to clear clipboard after copy-secret + Build/ T4 templates and version props +.github/workflows/release.yml CI release workflow +release.ps1 Local / CI publish script (requires MSBuild 17.14+) +release.md Step-by-step release instructions +tag_and_push.ps1 Creates and pushes a `v*` tag to trigger release +``` + +## Key constants — always keep these pointing to reysic + +`Vault/Explorer/Globals.cs` holds every URL string the app uses at runtime: + +```csharp +OnlineActivationUri = "https://reysic.github.io/AzureKeyVaultExplorer/VaultExplorer.application" +GitHubUrl = "https://github.com/reysic/AzureKeyVaultExplorer" +GitHubIssuesUrl = "https://github.com/reysic/AzureKeyVaultExplorer/issues" +ActivationUrl = "https://reysic.github.io/AzureKeyVaultExplorer/VaultExplorer.application" +``` + +`Vault/Explorer/Properties/PublishProfiles/ClickOnceProfile.pubxml` holds the ClickOnce +`InstallUrl`, `ErrorReportUrl`, and `SupportUrl` — these must also point to reysic. + +## How to build locally + +``` +dotnet build AzureKeyVaultExplorer.sln +``` + +Requirements: .NET 10 SDK (`dotnet --list-sdks` should show `10.x`). + +## How to publish (ClickOnce release) + +See `release.md`. Short version: + +1. Run `.\tag_and_push.ps1` — creates and pushes a `v*` tag. +2. GitHub Actions runs `release.ps1` which publishes with MSBuild and pushes output to `gh-pages`. +3. Users install / update from `https://reysic.github.io/AzureKeyVaultExplorer`. + +For local-only publish validation (no gh-pages push): +```powershell +.\release.ps1 -OnlyBuild +``` + +Requires: **Visual Studio 2022 17.14+** (MSBuild 17.14+ for .NET 10 ClickOnce). + +## Vault picker dialog (SubscriptionsManagerDialog) + +The "Pick vault from subscription..." flow in `MainForm.cs` opens +`Vault/Explorer/Dialogs/Subscriptions/SubscriptionsManagerDialog.cs`. + +Key behaviors to be aware of when editing it: + +- **Account → Tenant → Subscription → Vault** — cascading async selection. +- `uxButtonOK` starts **disabled**; it is only enabled after `Vaults.GetAsync` succeeds in + `uxListViewVaults_SelectedIndexChanged`. A try-catch wraps this call — errors show a warning + MessageBox but leave the dialog open so the user can pick a different vault. +- The onboarding prompt (no saved accounts) fires from the `Shown` event, not the constructor, + so the form is fully rendered before any MessageBox appears. +- `MinimumSize` is set in the Designer to prevent the window from being resized so small that + the OK/Cancel buttons are clipped. + +## UxOperation pattern + +`Common/UxOperation.cs` is used with `using` to show progress while async work runs: + +```csharp +using (var op = this.NewUxOperationWithProgress(controlsToDisable)) +{ + // async work here; op.CancellationToken is available +} +``` + +`Dispose()` re-enables controls, hides the progress bar, and resets the cursor. + +## Configuration files (user-editable) + +Located in the app's install folder or a user-specified "Root location": + +| File | Purpose | +|---|---| +| `Vaults.json` | Vault credential definitions | +| `VaultAliases.json` | Named vault aliases shown in the main dropdown | +| `SecretKinds.json` | Regex-validated secret types with tag schemas | +| `CustomTags.json` | Tag definitions referenced by SecretKinds | + +## Known open items + +- Deprecated Azure SDK packages (Microsoft.Azure.KeyVault, Microsoft.Azure.Management.KeyVault, + Microsoft.IdentityModel.Clients.ActiveDirectory, etc.) — migration to Track 2 non-preview + packages is a pending backlog item. +- PowerShell integration was removed (does not work with .NET 8+). + +## Coding conventions + +- Namespace prefix: `Microsoft.Vault.*` +- `async void` is used only for WinForms event handlers; wrap async body in try-catch. +- All user-visible strings are inline (no resource file abstraction needed for this tool). +- Designer-generated code lives in `*.Designer.cs`; do not edit `.resx` binary sections manually. diff --git a/README.md b/README.md index dfee705a..ff2a290c 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ # ![bigKey](./Screenshots/Key64x64.png) Azure Key Vault Explorer -**NOTE: This is a fork of the original project located at [https://github.com/microsoft/AzureKeyVaultExplorer](https://github.com/microsoft/AzureKeyVaultExplorer). This fork is not maintained by, or affiliated with, Microsoft, and was created to allow for continued development of the tool by the community.** +**NOTE: This repository is a fork of [reysic/AzureKeyVaultExplorer](https://github.com/reysic/AzureKeyVaultExplorer), which itself originated from [microsoft/AzureKeyVaultExplorer](https://github.com/microsoft/AzureKeyVaultExplorer). This fork is community-maintained and includes updates developed with GitHub Copilot assistance.** Azure Key Vault Explorer - be productive when working with secrets! -**[Click here to install the latest version (https://reysic.github.io/AzureKeyVaultExplorer)](https://reysic.github.io/AzureKeyVaultExplorer)** +**[Click here to install the latest version (VaultExplorer.application)](https://reysic.github.io/AzureKeyVaultExplorer/VaultExplorer.application)** + +If the install link does not load yet, enable **GitHub Pages** in this fork (`Settings -> Pages`) and publish from the branch that contains this repository. Original Authors: Eli Zeitlin, Gokhan Ozhan, Anna Zeitlin Current Authors: [reysic](https://github.com/reysic), [softworkz](https://github.com/softworkz) @@ -33,6 +35,26 @@ Contact: Submit issues/PRs on this repo * [Contributing](#contributing) * [TODOs](#todos) +## Recent Updates + +* **Tenant selector and account flow improvements** + * Sign in first, then select tenant from discovered tenants before loading subscriptions. + * Added support for saved accounts with known tenants and selectable default tenant behavior. + * Reduced duplicate login prompts in common account/tenant switch flows. +* **Subscription vault selection improvements** + * Better handling for "Pick vault from subscription..." and immediate vault activation after selecting a vault. + * Improved quick switching behavior with persisted last selected vault. + * Added clearer onboarding/error messages when no accounts or subscriptions are configured yet. +* **Platform/runtime modernization** + * Migrated Windows projects to **.NET 10** target frameworks. + * Updated build compatibility for current `dotnet` tooling and maintained vulnerability-clean package baseline. +* **Release and deployment hardening** + * ClickOnce release workflow is configured in `.github/workflows/release.yml` and uses `release.ps1`. + * Release docs are now fork-specific and include tag-driven publish steps (`release.md`). +* **Deprecated package validation status** + * Deprecated package scan was re-run and confirms legacy Azure SDK packages are still present. + * Migration to non-deprecated Azure SDK Track 2 packages remains an open refactor backlog item. + ## Key features * Best user experience for authentication, you will be prompted at most *once* to enter your credentials @@ -68,7 +90,7 @@ Contact: Submit issues/PRs on this repo There are 4 ways how you can make Vault Explorer to work with your vaults: -1. In case Vault Explorer is not installed on the box, you may just run: reysic.github.io/AzureKeyVaultExplorer?vault://[ENTER HERE YOUR VAULT NAME]` +1. In case Vault Explorer is not installed on the box, you may just run: `reysic.github.io/AzureKeyVaultExplorer?vault://[ENTER HERE YOUR VAULT NAME]` 2. In case Vault Explorer already installed on the box, you can just hit Win+R type `vault://[ENTER HERE YOUR VAULT NAME]` and hit Enter * Note: The above two methods do **NOT** allow for alternative account login 3. Run Vault Explorer, open vault combo box, select last item "Pick vault from subscription..." @@ -301,16 +323,18 @@ Telemetry can be disabled in the Settings dialog. Set *Disable telemetry* to *Tr ### Building -This project has been tested with Visual Studio 2022 and .NET Framework 4.8. To build locally: +This fork is tested with Visual Studio 2022 and .NET 10 SDK. To build locally: * Clone this repo -* Build and run +* Install .NET 10 SDK (`dotnet --list-sdks` should show `10.x`) +* Run `dotnet build AzureKeyVaultExplorer.sln` +* Run and test the application from `Vault\Explorer` PRs are welcome! ### Publishing -See [release.md](https://github.com/reysic/AzureKeyVaultExplorer/blob/c6c5153fc071ef74d306dff636df2f432d6dc27e/release.md). Following that process automatically triggers a couple of [Actions](https://github.com/reysic/AzureKeyVaultExplorer/actions), which run [release.ps1](https://github.com/reysic/AzureKeyVaultExplorer/blob/c6c5153fc071ef74d306dff636df2f432d6dc27e/release.ps1). +See [release.md](./release.md). Following that process triggers [Actions](https://github.com/reysic/AzureKeyVaultExplorer/actions), which run [release.ps1](https://github.com/reysic/AzureKeyVaultExplorer/blob/main/release.ps1). ### TODOs @@ -321,13 +345,14 @@ See [release.md](https://github.com/reysic/AzureKeyVaultExplorer/blob/c6c5153fc0 * Microsoft.IdentityModel.Clients.ActiveDirectory * Microsoft.Rest.ClientRuntime * Microsoft.Rest.ClientRuntime.Azure + * Status: Validation completed; migration to non-deprecated replacements is still pending. * Setup ClickOnce deployment * Re-establish existing ClickOnce install with new URL - :white_check_mark: * Document release process - :white_check_mark: * Configure ClickOnce deployments for future releases - :white_check_mark: * Improve onboarding - * Better error messaging if user hasn't configured subscription dialog values -* Update README.md to reflect latest state + * Better error messaging if user hasn't configured subscription dialog values - :white_check_mark: +* Update README.md to reflect latest state - :white_check_mark: #### softworkz Updates diff --git a/Vault/Build/t4.targets b/Vault/Build/t4.targets index 5e89211e..662d569b 100644 --- a/Vault/Build/t4.targets +++ b/Vault/Build/t4.targets @@ -1,150 +1,19 @@ - $(MSBuildThisFileDirectory)t4.exe - - . - - - - - - - - - - - - - - - getBoolFlag = (flag) => - { - flag = flag.Trim(); - - if (!String.IsNullOrEmpty(flag)) - { - return Boolean.Parse(flag); - } - - return false; - }; - - // - // Create a list of work items - // - var workItems = Inputs.Select(input => new - { - InputFile = input.ItemSpec, - - OutputFile = input.GetMetadata("T4OutputFile"), - Options = input.GetMetadata("T4AdditionalOptions"), - Preprocess = getBoolFlag(input.GetMetadata("T4PreprocessTemplate")), - - Output = new StringBuilder(), - }).ToList(); - - var exitCodes = new int[workItems.Count]; - - Log.LogMessage("(T4) Compiling {0} items ...", workItems.Count); - - // - // Start a task for each work item - // - var tasks = workItems.Select((wi, n) => - System.Threading.Tasks.Task.Factory.StartNew(delegate - { - var preprocess = wi.Preprocess; - - var psi = new ProcessStartInfo() - { - FileName = ToolPath, - Arguments = (preprocess ? "-pp " : "") + wi.InputFile + " " + wi.Options + " -out " + wi.OutputFile, - - UseShellExecute = false, CreateNoWindow = true, - RedirectStandardOutput = true, RedirectStandardError = true, - }; - - using (var t4Proc = new Process()) - { - t4Proc.OutputDataReceived += (s, e) => { wi.Output.AppendLine(e.Data); }; - t4Proc.ErrorDataReceived += (s, e) => { wi.Output.AppendLine(e.Data); }; - - t4Proc.StartInfo = psi; - t4Proc.Start(); - - t4Proc.BeginOutputReadLine(); - t4Proc.BeginErrorReadLine(); - t4Proc.WaitForExit(); - - t4Proc.CancelOutputRead(); - t4Proc.CancelErrorRead(); - - exitCodes[n] = t4Proc.ExitCode; - } - - }, TaskCreationOptions.LongRunning)).ToArray(); - - // - // Wait for all tasks to complete - // - System.Threading.Tasks.Task.WaitAll(tasks); - - var success = true; - - for (int n = 0; n < workItems.Count; n++) - { - var wi = workItems[n]; - - var output = wi.Output.ToString().Trim(); - - if (!String.IsNullOrEmpty(output)) - { - Log.LogMessage(MessageImportance.High, output); - } - - var exitCode = exitCodes[n]; - - if (exitCode != 0) - { - success = false; - } - } - - // - // Indicate overall success or failure - // - if (!success) - { - Log.LogError("T4Transform task failed!"); - } - - return success; - } - ]]> - - - - $(T4Output)\%(RelativeDir)%(Filename) - - + + false @@ -152,32 +21,20 @@ BeforeBuild - - + - - + BeforeTargets="$(T4BeforeTargets)" + AfterTargets="$(T4AfterTargets)" + Inputs="@(T4Compile);%(T4AdditionalInputs)" + Outputs="%(T4OutputFile)%(T4ForceOutput)"> - - - - - - + - - - + - diff --git a/Vault/ClearClipboard/ClearClipboard.csproj b/Vault/ClearClipboard/ClearClipboard.csproj index 5c265359..d6952b60 100644 --- a/Vault/ClearClipboard/ClearClipboard.csproj +++ b/Vault/ClearClipboard/ClearClipboard.csproj @@ -1,9 +1,9 @@  - net8.0-windows10.0.17763.0 + net10.0-windows10.0.17763.0 Exe true - true + false true disable False @@ -14,4 +14,4 @@ win-x64 True - \ No newline at end of file + diff --git a/Vault/Explorer/Common/ActivationUri.cs b/Vault/Explorer/Common/ActivationUri.cs index 7d252203..f42acc4a 100644 --- a/Vault/Explorer/Common/ActivationUri.cs +++ b/Vault/Explorer/Common/ActivationUri.cs @@ -45,6 +45,12 @@ public ActivationUri(string vaultUri) : base(vaultUri) return Empty; } + // Ignore ClickOnce deployment URIs that do not include a vault: payload. + if (!vaultUri.StartsWith("vault:", StringComparison.CurrentCultureIgnoreCase)) + { + return Empty; + } + return new ActivationUri(vaultUri.TrimEnd('/', '\\')); } @@ -69,8 +75,9 @@ private void CopyToClipboard(Vault vault) return; case VaultUriCollection.Certificates: var cb = vault.GetCertificateAsync(this.ItemName, this.Version, CancellationToken.None).GetAwaiter().GetResult(); + var cbPolicy = vault.GetCertificatePolicyAsync(this.ItemName, CancellationToken.None).GetAwaiter().GetResult(); var cert = vault.GetCertificateWithExportableKeysAsync(this.ItemName, this.Version, CancellationToken.None).GetAwaiter().GetResult(); - po = new PropertyObjectCertificate(cb, cb.Policy, cert, null); + po = new PropertyObjectCertificate(cb, cbPolicy, cert, null); break; case VaultUriCollection.Secrets: var s = vault.GetSecretAsync(this.ItemName, this.Version, CancellationToken.None).GetAwaiter().GetResult(); @@ -124,4 +131,4 @@ public static void RegisterVaultProtocol() } } } -} \ No newline at end of file +} diff --git a/Vault/Explorer/Common/Utils.cs b/Vault/Explorer/Common/Utils.cs index a6c93642..e0088089 100644 --- a/Vault/Explorer/Common/Utils.cs +++ b/Vault/Explorer/Common/Utils.cs @@ -197,7 +197,7 @@ public static string NewSecurePassword() var n = Enumerable.Range(0, 1).Select(i => NumbersSet[r.Next(0, NumbersSet.Length)]); var s = Enumerable.Range(0, 4).Select(i => SpecialCharsSet[r.Next(0, SpecialCharsSet.Length)]); var a = Enumerable.Range(0, length - 11).Select(i => All[r.Next(0, All.Length)]); - return Encoding.ASCII.GetString(u.Concat(l).Concat(n).Concat(s).Concat(a).Shuffle().ToArray()); + return Encoding.ASCII.GetString(Microsoft.Vault.Library.Utils.Shuffle(u.Concat(l).Concat(n).Concat(s).Concat(a)).ToArray()); } } @@ -316,4 +316,4 @@ public static void ShowToast(string body) ToastNotificationManager.CreateToastNotifier(Globals.AppName).Show(toast); } } -} \ No newline at end of file +} diff --git a/Vault/Explorer/Common/UxOperation.cs b/Vault/Explorer/Common/UxOperation.cs index 587a82a5..ce98d8d8 100644 --- a/Vault/Explorer/Common/UxOperation.cs +++ b/Vault/Explorer/Common/UxOperation.cs @@ -9,8 +9,7 @@ namespace Microsoft.Vault.Explorer.Common using System.Threading; using System.Threading.Tasks; using System.Windows.Forms; - using Microsoft.Azure.KeyVault.Models; - using Microsoft.Rest.Azure; + using Azure; using Microsoft.Vault.Explorer.Model.Files.Aliases; /// @@ -95,13 +94,9 @@ public async Task Invoke(string actionName, params Func[] tasks) { await t(); } - catch (CloudException ce) when (ce.Response?.StatusCode == System.Net.HttpStatusCode.Forbidden) + catch (RequestFailedException rfe) when (rfe.Status == 403) { - exceptions.Enqueue(ce); - } - catch (KeyVaultErrorException kvce) when (kvce.Response?.StatusCode == System.Net.HttpStatusCode.Forbidden) - { - exceptions.Enqueue(kvce); + exceptions.Enqueue(rfe); } })); } diff --git a/Vault/Explorer/Controls/Lists/ListViewItemBase.cs b/Vault/Explorer/Controls/Lists/ListViewItemBase.cs index 6ee04806..fd7c6462 100644 --- a/Vault/Explorer/Controls/Lists/ListViewItemBase.cs +++ b/Vault/Explorer/Controls/Lists/ListViewItemBase.cs @@ -12,7 +12,6 @@ namespace Microsoft.Vault.Explorer.Controls.Lists using System.Threading; using System.Threading.Tasks; using System.Windows.Forms; - using Microsoft.Azure.KeyVault; using Microsoft.Vault.Explorer.Common; using Microsoft.Vault.Explorer.Controls.Lists.Favorites; using Microsoft.Vault.Explorer.Model; diff --git a/Vault/Explorer/Controls/Lists/ListViewItemCertificate.cs b/Vault/Explorer/Controls/Lists/ListViewItemCertificate.cs index 4d2adc58..2c85bc44 100644 --- a/Vault/Explorer/Controls/Lists/ListViewItemCertificate.cs +++ b/Vault/Explorer/Controls/Lists/ListViewItemCertificate.cs @@ -11,40 +11,56 @@ namespace Microsoft.Vault.Explorer.Controls.Lists using System.Threading; using System.Threading.Tasks; using System.Windows.Forms; - using Microsoft.Azure.KeyVault; - using Microsoft.Azure.KeyVault.Models; + using Azure.Security.KeyVault.Certificates; using Microsoft.Vault.Explorer.Common; using Microsoft.Vault.Explorer.Dialogs.Certificates; using Microsoft.Vault.Explorer.Model; using Microsoft.Vault.Explorer.Model.ContentTypes; using Microsoft.Vault.Explorer.Model.PropObjects; + using Microsoft.Vault.Library; /// /// Key Vault Certificate list view item which also presents itself nicely to PropertyGrid /// public class ListViewItemCertificate : ListViewItemBase { - public readonly CertificateAttributes Attributes; public readonly string Thumbprint; - private ListViewItemCertificate(ISession session, CertificateIdentifier identifier, CertificateAttributes attributes, string thumbprint, IDictionary tags) : - base(session, KeyVaultCertificatesGroup, identifier, tags, attributes.Enabled, attributes.Created, attributes.Updated, attributes.NotBefore, attributes.Expires) + private ListViewItemCertificate(ISession session, ObjectIdentifier identifier, string thumbprint, IDictionary tags, bool? enabled, DateTime? created, DateTime? updated, DateTime? notBefore, DateTime? expires) : + base(session, KeyVaultCertificatesGroup, identifier, tags, enabled, created, updated, notBefore, expires) { - this.Attributes = attributes; this.Thumbprint = thumbprint?.ToLowerInvariant(); } - public ListViewItemCertificate(ISession session, CertificateItem c) : this(session, c.Identifier, c.Attributes, Utils.ByteArrayToHex(c.X509Thumbprint), c.Tags) + public ListViewItemCertificate(ISession session, CertificateProperties cp) : this( + session, + new ObjectIdentifier(cp.Name, cp.Id?.ToString() ?? string.Empty, cp.Version ?? string.Empty, cp.VaultUri?.ToString() ?? string.Empty), + Utils.ByteArrayToHex(cp.X509Thumbprint), + cp.Tags, + cp.Enabled, + cp.CreatedOn?.UtcDateTime, + cp.UpdatedOn?.UtcDateTime, + cp.NotBefore?.UtcDateTime, + cp.ExpiresOn?.UtcDateTime) { } - public ListViewItemCertificate(ISession session, CertificateBundle cb) : this(session, cb.CertificateIdentifier, cb.Attributes, Utils.ByteArrayToHex(cb.X509Thumbprint), cb.Tags) + public ListViewItemCertificate(ISession session, KeyVaultCertificate kvc) : this( + session, + new ObjectIdentifier(kvc.Name, kvc.Id?.ToString() ?? string.Empty, kvc.Properties.Version ?? string.Empty, kvc.Properties.VaultUri?.ToString() ?? string.Empty), + Utils.ByteArrayToHex(kvc.Properties.X509Thumbprint), + kvc.Properties.Tags, + kvc.Properties.Enabled, + kvc.Properties.CreatedOn?.UtcDateTime, + kvc.Properties.UpdatedOn?.UtcDateTime, + kvc.Properties.NotBefore?.UtcDateTime, + kvc.Properties.ExpiresOn?.UtcDateTime) { } protected override IEnumerable GetCustomProperties() { - yield return new ReadOnlyPropertyDescriptor("Content Type", CertificateContentType.Pfx); + yield return new ReadOnlyPropertyDescriptor("Content Type", CertificateContentType.Pkcs12.ToString()); yield return new ReadOnlyPropertyDescriptor("Thumbprint", this.Thumbprint); } @@ -52,26 +68,36 @@ protected override IEnumerable GetCustomProperties() public override async Task GetAsync(CancellationToken cancellationToken) { - var cb = await this.Session.CurrentVault.GetCertificateAsync(this.Name, null, cancellationToken); + var kvc = await this.Session.CurrentVault.GetCertificateAsync(this.Name, null, cancellationToken); + var policy = await this.Session.CurrentVault.GetCertificatePolicyAsync(this.Name, cancellationToken); var cert = await this.Session.CurrentVault.GetCertificateWithExportableKeysAsync(this.Name, null, cancellationToken); - return new PropertyObjectCertificate(cb, cb.Policy, cert, null); + return new PropertyObjectCertificate(kvc, policy, cert, null); } public override async Task ToggleAsync(CancellationToken cancellationToken) { - CertificateBundle cb = await this.Session.CurrentVault.UpdateCertificateAsync(this.Name, null, null, new CertificateAttributes { Enabled = !this.Attributes.Enabled }, this.Tags, cancellationToken); // Toggle only Enabled attribute - return new ListViewItemCertificate(this.Session, cb); + var kvc = await this.Session.CurrentVault.UpdateCertificateAsync( + this.Name, null, + !this.Enabled, + this.Expires.HasValue ? (DateTimeOffset?)this.Expires.Value : null, + this.NotBefore.HasValue ? (DateTimeOffset?)this.NotBefore.Value : null, + this.Tags, + cancellationToken); + return new ListViewItemCertificate(this.Session, kvc); } public override async Task ResetExpirationAsync(CancellationToken cancellationToken) { - var ca = new CertificateAttributes - { - NotBefore = this.NotBefore == null ? null : DateTime.UtcNow.AddHours(-1), - Expires = this.Expires == null ? null : DateTime.UtcNow.AddYears(1), - }; - CertificateBundle cb = await this.Session.CurrentVault.UpdateCertificateAsync(this.Name, null, null, ca, this.Tags, cancellationToken); // Reset only NotBefore and Expires attributes - return new ListViewItemCertificate(this.Session, cb); + DateTimeOffset? newExpires = this.Expires == null ? null : (DateTimeOffset?)DateTimeOffset.UtcNow.AddYears(1); + DateTimeOffset? newNotBefore = this.NotBefore == null ? null : (DateTimeOffset?)DateTimeOffset.UtcNow.AddHours(-1); + var kvc = await this.Session.CurrentVault.UpdateCertificateAsync( + this.Name, null, + this.Enabled, + newExpires, + newNotBefore, + this.Tags, + cancellationToken); + return new ListViewItemCertificate(this.Session, kvc); } public override async Task DeleteAsync(CancellationToken cancellationToken) @@ -87,16 +113,21 @@ public override async Task> GetVersionsAsync(CancellationTok public override Form GetEditDialog(string name, IEnumerable versions) { - return new CertificateDialog(this.Session, name, versions.Cast()); + return new CertificateDialog(this.Session, name, versions.Cast()); } public override async Task UpdateAsync(object originalObject, PropertyObject newObject, CancellationToken cancellationToken) { - CertificateBundle cb = (CertificateBundle)originalObject; PropertyObjectCertificate certNew = (PropertyObjectCertificate)newObject; await this.Session.CurrentVault.UpdateCertificatePolicyAsync(certNew.Name, certNew.CertificatePolicy, cancellationToken); - cb = await this.Session.CurrentVault.UpdateCertificateAsync(certNew.Name, null, null, certNew.ToCertificateAttributes(), certNew.ToTagsDictionary(), cancellationToken); - return new ListViewItemCertificate(this.Session, cb); + var kvc = await this.Session.CurrentVault.UpdateCertificateAsync( + certNew.Name, null, + certNew.Enabled, + certNew.Expires.HasValue ? (DateTimeOffset?)certNew.Expires.Value : null, + certNew.NotBefore.HasValue ? (DateTimeOffset?)certNew.NotBefore.Value : null, + certNew.ToTagsDictionary(), + cancellationToken); + return new ListViewItemCertificate(this.Session, kvc); } public static async Task NewAsync(ISession session, PropertyObject newObject, CancellationToken cancellationToken) @@ -104,8 +135,12 @@ public static async Task NewAsync(ISession session, Pro PropertyObjectCertificate certNew = (PropertyObjectCertificate)newObject; var certCollection = new X509Certificate2Collection(); certCollection.Add(certNew.Certificate); - CertificateBundle cb = await session.CurrentVault.ImportCertificateAsync(certNew.Name, certCollection, certNew.CertificatePolicy, certNew.CertificateBundle.Attributes, certNew.ToTagsDictionary(), cancellationToken); - return new ListViewItemCertificate(session, cb); + var kvc = await session.CurrentVault.ImportCertificateAsync( + certNew.Name, certCollection, certNew.CertificatePolicy, + certNew.Enabled, + certNew.ToTagsDictionary(), + cancellationToken); + return new ListViewItemCertificate(session, kvc); } } -} \ No newline at end of file +} diff --git a/Vault/Explorer/Controls/Lists/ListViewItemSecret.cs b/Vault/Explorer/Controls/Lists/ListViewItemSecret.cs index 7fb9bad9..c1178d39 100644 --- a/Vault/Explorer/Controls/Lists/ListViewItemSecret.cs +++ b/Vault/Explorer/Controls/Lists/ListViewItemSecret.cs @@ -10,37 +10,53 @@ namespace Microsoft.Vault.Explorer.Controls.Lists using System.Threading; using System.Threading.Tasks; using System.Windows.Forms; - using Microsoft.Azure.KeyVault; - using Microsoft.Azure.KeyVault.Models; + using Azure.Security.KeyVault.Secrets; using Microsoft.Vault.Explorer.Common; using Microsoft.Vault.Explorer.Dialogs.Secrets; using Microsoft.Vault.Explorer.Model; using Microsoft.Vault.Explorer.Model.ContentTypes; using Microsoft.Vault.Explorer.Model.PropObjects; + using Microsoft.Vault.Library; /// /// Secret list view item which also presents itself nicely to PropertyGrid /// public class ListViewItemSecret : ListViewItemBase { - public readonly SecretAttributes Attributes; public readonly string ContentTypeStr; public readonly ContentType ContentType; - private ListViewItemSecret(ISession session, SecretIdentifier identifier, SecretAttributes attributes, string contentTypeStr, IDictionary tags) : + private ListViewItemSecret(ISession session, ObjectIdentifier identifier, string contentTypeStr, IDictionary tags, bool? enabled, DateTime? created, DateTime? updated, DateTime? notBefore, DateTime? expires) : base(session, ContentTypeEnumConverter.GetValue(contentTypeStr).IsCertificate() ? CertificatesGroup : SecretsGroup, - identifier, tags, attributes.Enabled, attributes.Created, attributes.Updated, attributes.NotBefore, attributes.Expires) + identifier, tags, enabled, created, updated, notBefore, expires) { - this.Attributes = attributes; this.ContentTypeStr = contentTypeStr; this.ContentType = ContentTypeEnumConverter.GetValue(contentTypeStr); } - public ListViewItemSecret(ISession session, SecretItem si) : this(session, si.Identifier, si.Attributes, si.ContentType, si.Tags) + public ListViewItemSecret(ISession session, SecretProperties sp) : this( + session, + new ObjectIdentifier(sp.Name, sp.Id?.ToString() ?? string.Empty, sp.Version ?? string.Empty, sp.VaultUri?.ToString() ?? string.Empty), + sp.ContentType, + sp.Tags, + sp.Enabled, + sp.CreatedOn?.UtcDateTime, + sp.UpdatedOn?.UtcDateTime, + sp.NotBefore?.UtcDateTime, + sp.ExpiresOn?.UtcDateTime) { } - public ListViewItemSecret(ISession session, SecretBundle s) : this(session, s.SecretIdentifier, s.Attributes, s.ContentType, s.Tags) + public ListViewItemSecret(ISession session, KeyVaultSecret s) : this( + session, + new ObjectIdentifier(s.Name, s.Id?.ToString() ?? string.Empty, s.Properties.Version ?? string.Empty, s.Properties.VaultUri?.ToString() ?? string.Empty), + s.Properties.ContentType, + s.Properties.Tags, + s.Properties.Enabled, + s.Properties.CreatedOn?.UtcDateTime, + s.Properties.UpdatedOn?.UtcDateTime, + s.Properties.NotBefore?.UtcDateTime, + s.Properties.ExpiresOn?.UtcDateTime) { } @@ -59,19 +75,30 @@ public override async Task GetAsync(CancellationToken cancellati public override async Task ToggleAsync(CancellationToken cancellationToken) { - SecretBundle s = await this.Session.CurrentVault.UpdateSecretAsync(this.Name, null, new Dictionary(this.Tags), null, new SecretAttributes { Enabled = !this.Attributes.Enabled }, cancellationToken); // Toggle only Enabled attribute - return new ListViewItemSecret(this.Session, s); + var sp = await this.Session.CurrentVault.UpdateSecretAsync( + this.Name, null, + new Dictionary(this.Tags), + this.ContentTypeStr, + !this.Enabled, + this.Expires.HasValue ? (DateTimeOffset?)this.Expires.Value : null, + this.NotBefore.HasValue ? (DateTimeOffset?)this.NotBefore.Value : null, + cancellationToken); + return new ListViewItemSecret(this.Session, sp); } public override async Task ResetExpirationAsync(CancellationToken cancellationToken) { - var sa = new SecretAttributes - { - NotBefore = this.NotBefore == null ? null : DateTime.UtcNow.AddHours(-1), - Expires = this.Expires == null ? null : DateTime.UtcNow.AddYears(1), - }; - SecretBundle s = await this.Session.CurrentVault.UpdateSecretAsync(this.Name, null, new Dictionary(this.Tags), null, sa, cancellationToken); // Reset only NotBefore and Expires attributes - return new ListViewItemSecret(this.Session, s); + DateTimeOffset? newExpires = this.Expires == null ? null : (DateTimeOffset?)DateTimeOffset.UtcNow.AddYears(1); + DateTimeOffset? newNotBefore = this.NotBefore == null ? null : (DateTimeOffset?)DateTimeOffset.UtcNow.AddHours(-1); + var sp = await this.Session.CurrentVault.UpdateSecretAsync( + this.Name, null, + new Dictionary(this.Tags), + this.ContentTypeStr, + this.Enabled, + newExpires, + newNotBefore, + cancellationToken); + return new ListViewItemSecret(this.Session, sp); } public override async Task DeleteAsync(CancellationToken cancellationToken) @@ -87,31 +114,42 @@ public override async Task> GetVersionsAsync(CancellationTok public override Form GetEditDialog(string name, IEnumerable versions) { - return new SecretDialog(this.Session, name, versions.Cast()); + return new SecretDialog(this.Session, name, versions.Cast()); } private static async Task NewOrUpdateAsync(ISession session, object originalObject, PropertyObject newObject, CancellationToken cancellationToken) { - SecretBundle sOriginal = (SecretBundle)originalObject; + KeyVaultSecret sOriginal = (KeyVaultSecret)originalObject; PropertyObjectSecret posNew = (PropertyObjectSecret)newObject; - SecretBundle s = null; - // New secret, secret rename or new value - if (sOriginal == null || sOriginal.SecretIdentifier.Name != posNew.Name || sOriginal.Value != posNew.RawValue) + + DateTimeOffset? expires = posNew.Expires.HasValue ? (DateTimeOffset?)posNew.Expires.Value : null; + DateTimeOffset? notBefore = posNew.NotBefore.HasValue ? (DateTimeOffset?)posNew.NotBefore.Value : null; + + // New secret, rename, or new value + if (sOriginal == null || sOriginal.Name != posNew.Name || sOriginal.Value != posNew.RawValue) { - s = await session.CurrentVault.SetSecretAsync(posNew.Name, posNew.RawValue, posNew.ToTagsDictionary(), ContentTypeEnumConverter.GetDescription(posNew.ContentType), posNew.ToSecretAttributes(), cancellationToken); + var s = await session.CurrentVault.SetSecretAsync( + posNew.Name, posNew.RawValue, posNew.ToTagsDictionary(), + ContentTypeEnumConverter.GetDescription(posNew.ContentType), + posNew.Enabled, expires, notBefore, cancellationToken); + + string oldSecretName = sOriginal?.Name; + if (oldSecretName != null && oldSecretName != posNew.Name) + { + await session.CurrentVault.DeleteSecretAsync(oldSecretName, cancellationToken); + } + + return new ListViewItemSecret(session, s); } - else // Same secret name and value + else // Same secret name and value — update metadata only { - s = await session.CurrentVault.UpdateSecretAsync(posNew.Name, null, posNew.ToTagsDictionary(), ContentTypeEnumConverter.GetDescription(posNew.ContentType), posNew.ToSecretAttributes(), cancellationToken); - } + var sp = await session.CurrentVault.UpdateSecretAsync( + posNew.Name, null, posNew.ToTagsDictionary(), + ContentTypeEnumConverter.GetDescription(posNew.ContentType), + posNew.Enabled, expires, notBefore, cancellationToken); - string oldSecretName = sOriginal?.SecretIdentifier.Name; - if (oldSecretName != null && oldSecretName != posNew.Name) // Delete old secret - { - await session.CurrentVault.DeleteSecretAsync(oldSecretName, cancellationToken); + return new ListViewItemSecret(session, sp); } - - return new ListViewItemSecret(session, s); } public override async Task UpdateAsync(object originalObject, PropertyObject newObject, CancellationToken cancellationToken) @@ -124,4 +162,4 @@ public static Task NewAsync(ISession session, PropertyObject return NewOrUpdateAsync(session, null, newObject, cancellationToken); } } -} \ No newline at end of file +} diff --git a/Vault/Explorer/Controls/Lists/ListViewSecrets.Designer.cs b/Vault/Explorer/Controls/Lists/ListViewSecrets.Designer.cs index 3d7ae5f7..adb8b992 100644 --- a/Vault/Explorer/Controls/Lists/ListViewSecrets.Designer.cs +++ b/Vault/Explorer/Controls/Lists/ListViewSecrets.Designer.cs @@ -60,7 +60,7 @@ private void InitializeComponent() // // columnHeader4 // - this.columnHeader4.Text = "Expires"; + this.columnHeader4.Text = "Expires in"; this.columnHeader4.Width = 100; // // uxSmallImageList diff --git a/Vault/Explorer/Controls/Lists/ListViewSecrets.cs b/Vault/Explorer/Controls/Lists/ListViewSecrets.cs index 2de0bdd1..d90f15ed 100644 --- a/Vault/Explorer/Controls/Lists/ListViewSecrets.cs +++ b/Vault/Explorer/Controls/Lists/ListViewSecrets.cs @@ -4,6 +4,7 @@ namespace Microsoft.Vault.Explorer.Controls.Lists { using System; + using System.ComponentModel; using System.Collections.Generic; using System.IO; using System.Linq; @@ -16,6 +17,7 @@ public partial class ListViewSecrets : ListView { public const int FirstCustomColumnIndex = 4; + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] public int SortingColumn { get; set; } public ListViewSecrets() @@ -210,4 +212,4 @@ protected override void WndProc(ref Message m) private const int WM_CONTEXTMENU = 0x7B; private readonly Dictionary _tags; } -} \ No newline at end of file +} diff --git a/Vault/Explorer/Controls/MenuItems/CertificateVersion.cs b/Vault/Explorer/Controls/MenuItems/CertificateVersion.cs index 8829798b..47ccc03d 100644 --- a/Vault/Explorer/Controls/MenuItems/CertificateVersion.cs +++ b/Vault/Explorer/Controls/MenuItems/CertificateVersion.cs @@ -1,14 +1,24 @@ namespace Microsoft.Vault.Explorer.Controls.MenuItems { - using Microsoft.Azure.KeyVault.Models; + using Azure.Security.KeyVault.Certificates; + using Microsoft.Vault.Library; public class CertificateVersion : CustomVersion { - public readonly CertificateItem CertificateItem; + public readonly CertificateProperties CertificateItem; - public CertificateVersion(int index, CertificateItem certificateItem) : base(index, certificateItem.Attributes.Created, certificateItem.Attributes.Updated, Library.Utils.GetChangedBy(certificateItem.Tags), certificateItem.Identifier) + public CertificateVersion(int index, CertificateProperties certificateItem) : base( + index, + certificateItem.CreatedOn?.UtcDateTime, + certificateItem.UpdatedOn?.UtcDateTime, + Library.Utils.GetChangedBy(certificateItem.Tags), + new ObjectIdentifier( + certificateItem.Name, + certificateItem.Id?.ToString() ?? string.Empty, + certificateItem.Version ?? string.Empty, + certificateItem.VaultUri?.ToString() ?? string.Empty)) { this.CertificateItem = certificateItem; } } -} \ No newline at end of file +} diff --git a/Vault/Explorer/Controls/MenuItems/CustomVersion.cs b/Vault/Explorer/Controls/MenuItems/CustomVersion.cs index 55ebcb1b..f1745e98 100644 --- a/Vault/Explorer/Controls/MenuItems/CustomVersion.cs +++ b/Vault/Explorer/Controls/MenuItems/CustomVersion.cs @@ -5,8 +5,8 @@ namespace Microsoft.Vault.Explorer.Controls.MenuItems { using System; using System.Windows.Forms; - using Microsoft.Azure.KeyVault; using Microsoft.Vault.Explorer.Common; + using Microsoft.Vault.Library; public abstract class CustomVersion : ToolStripMenuItem { diff --git a/Vault/Explorer/Controls/MenuItems/SecretVersion.cs b/Vault/Explorer/Controls/MenuItems/SecretVersion.cs index cdb9645e..b5994fd5 100644 --- a/Vault/Explorer/Controls/MenuItems/SecretVersion.cs +++ b/Vault/Explorer/Controls/MenuItems/SecretVersion.cs @@ -1,14 +1,24 @@ namespace Microsoft.Vault.Explorer.Controls.MenuItems { - using Microsoft.Azure.KeyVault.Models; + using Azure.Security.KeyVault.Secrets; + using Microsoft.Vault.Library; public class SecretVersion : CustomVersion { - public readonly SecretItem SecretItem; + public readonly SecretProperties SecretItem; - public SecretVersion(int index, SecretItem secretItem) : base(index, secretItem.Attributes.Created, secretItem.Attributes.Updated, Library.Utils.GetChangedBy(secretItem.Tags), secretItem.Identifier) + public SecretVersion(int index, SecretProperties secretItem) : base( + index, + secretItem.CreatedOn?.UtcDateTime, + secretItem.UpdatedOn?.UtcDateTime, + Library.Utils.GetChangedBy(secretItem.Tags), + new ObjectIdentifier( + secretItem.Name, + secretItem.Id?.ToString() ?? string.Empty, + secretItem.Version ?? string.Empty, + secretItem.VaultUri?.ToString() ?? string.Empty)) { this.SecretItem = secretItem; } } -} \ No newline at end of file +} diff --git a/Vault/Explorer/Dialogs/Certificates/CertificateDialog.cs b/Vault/Explorer/Dialogs/Certificates/CertificateDialog.cs index 78833ccf..099a455d 100644 --- a/Vault/Explorer/Dialogs/Certificates/CertificateDialog.cs +++ b/Vault/Explorer/Dialogs/Certificates/CertificateDialog.cs @@ -11,7 +11,7 @@ namespace Microsoft.Vault.Explorer.Dialogs.Certificates using System.Security.Cryptography.X509Certificates; using System.Threading.Tasks; using System.Windows.Forms; - using Microsoft.Azure.KeyVault.Models; + using Azure.Security.KeyVault.Certificates; using Microsoft.Vault.Explorer.Common; using Microsoft.Vault.Explorer.Controls.MenuItems; using Microsoft.Vault.Explorer.Dialogs.Passwords; @@ -34,13 +34,13 @@ private CertificateDialog(ISession session, string title, ItemDialogBaseMode mod /// public CertificateDialog(ISession session, FileInfo fi) : this(session, "New certificate", ItemDialogBaseMode.New) { - CertificateBundle cb = null; + KeyVaultCertificate cb = null; X509Certificate2 cert = null; ContentType contentType = ContentTypeUtils.FromExtension(fi.Extension); switch (contentType) { case ContentType.Certificate: - cert = new X509Certificate2(fi.FullName); + cert = X509CertificateLoader.LoadCertificateFromFile(fi.FullName); break; case ContentType.Pkcs12: string password = null; @@ -52,12 +52,12 @@ public CertificateDialog(ISession session, FileInfo fi) : this(session, "New cer } password = pwdDlg.Password; - cert = new X509Certificate2(fi.FullName, password, X509KeyStorageFlags.UserKeySet | X509KeyStorageFlags.Exportable); + cert = X509CertificateLoader.LoadPkcs12FromFile(fi.FullName, password, X509KeyStorageFlags.UserKeySet | X509KeyStorageFlags.Exportable, Pkcs12LoaderLimits.Defaults); break; case ContentType.KeyVaultCertificate: var kvcf = Utils.LoadFromJsonFile(fi.FullName); - cb = kvcf.Deserialize(); - cert = new X509Certificate2(cb.Cer); + var cfd = kvcf.Deserialize(); + cert = X509CertificateLoader.LoadCertificate(cfd.Cer); break; default: throw new ArgumentException($"Unsupported ContentType {contentType}"); @@ -77,40 +77,29 @@ public CertificateDialog(ISession session, X509Certificate2 cert) : this(session /// /// Edit certificate /// - public CertificateDialog(ISession session, string name, IEnumerable versions) : this(session, $"Edit certificate {name}", ItemDialogBaseMode.Edit) + public CertificateDialog(ISession session, string name, IEnumerable versions) : this(session, $"Edit certificate {name}", ItemDialogBaseMode.Edit) { this.uxTextBoxName.ReadOnly = true; int i = 0; - this.uxMenuVersions.Items.AddRange((from v in versions orderby v.Attributes.Created descending select new CertificateVersion(i++, v)).ToArray()); + this.uxMenuVersions.Items.AddRange((from v in versions orderby v.CreatedOn descending select new CertificateVersion(i++, v)).ToArray()); this.uxMenuVersions_ItemClicked(null, new ToolStripItemClickedEventArgs(this.uxMenuVersions.Items[0])); // Pass sender as NULL so _changed will be set to false } - private void NewCertificate(CertificateBundle cb, X509Certificate2 cert) + private void NewCertificate(KeyVaultCertificate cb, X509Certificate2 cert) { - this._certificatePolicy = cb?.Policy; - this._certificatePolicy = this._certificatePolicy ?? new CertificatePolicy + this._certificatePolicy = this._certificatePolicy ?? new CertificatePolicy("Self") { - KeyProperties = new KeyProperties - { - Exportable = true, - KeySize = 2048, - KeyType = "RSA", - ReuseKey = false, - }, - SecretProperties = new SecretProperties - { - ContentType = CertificateContentType.Pfx, - }, - }; - cb = cb ?? new CertificateBundle - { - Attributes = new CertificateAttributes(), + Exportable = true, + KeySize = 2048, + KeyType = "RSA", + ReuseKey = false, + ContentType = CertificateContentType.Pkcs12, }; this.RefreshCertificateObject(cb, this._certificatePolicy, cert); this.uxTextBoxName.Text = Utils.ConvertToValidSecretName(cert.GetNameInfo(X509NameType.SimpleName, false)); } - private void RefreshCertificateObject(CertificateBundle cb, CertificatePolicy cp, X509Certificate2 certificate) + private void RefreshCertificateObject(KeyVaultCertificate cb, CertificatePolicy cp, X509Certificate2 certificate) { this.uxPropertyGridSecret.SelectedObject = this.PropertyObject = new PropertyObjectCertificate(cb, cp, certificate, this.SecretObject_PropertyChanged); this.uxTextBoxName.Text = this.PropertyObject.Name; @@ -131,15 +120,15 @@ private void SecretObject_PropertyChanged(object sender, PropertyChangedEventArg protected override async Task OnVersionChangeAsync(CustomVersion cv) { - var cb = await this._session.CurrentVault.GetCertificateAsync(cv.Id.Name, cv.Index == 0 ? null : cv.Id.Version); // Pass NULL as a version to fetch current CertificatePolicy + var cb = await this._session.CurrentVault.GetCertificateAsync(cv.Id.Name, cv.Index == 0 ? null : cv.Id.Version); var cert = await this._session.CurrentVault.GetCertificateWithExportableKeysAsync(cv.Id.Name, cv.Id.Version); - if (this._certificatePolicy == null && cb.Policy != null) // cb.Policy will be NULL when version is not current + if (this._certificatePolicy == null && cv.Index == 0) // Only fetch policy for current version { - this._certificatePolicy = cb.Policy; + this._certificatePolicy = await this._session.CurrentVault.GetCertificatePolicyAsync(cv.Id.Name); } this.RefreshCertificateObject(cb, this._certificatePolicy, cert); return cb; } } -} \ No newline at end of file +} diff --git a/Vault/Explorer/Dialogs/ItemDialogBase.cs b/Vault/Explorer/Dialogs/ItemDialogBase.cs index f4016d0b..80dc2ab7 100644 --- a/Vault/Explorer/Dialogs/ItemDialogBase.cs +++ b/Vault/Explorer/Dialogs/ItemDialogBase.cs @@ -4,6 +4,7 @@ namespace Microsoft.Vault.Explorer.Dialogs { using System; + using System.ComponentModel; using System.Threading.Tasks; using System.Windows.Forms; using Microsoft.Vault.Explorer.Controls.MenuItems; @@ -16,6 +17,7 @@ public partial class ItemDialogBase : Form protected readonly ItemDialogBaseMode _mode; protected bool _changed; public object OriginalObject; // Will be NULL in New mode and current value in case of Edit mode + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] public PropertyObject PropertyObject { get; protected set; } public ItemDialogBase() @@ -94,4 +96,4 @@ private void uxLinkLabelValue_LinkClicked(object sender, LinkLabelLinkClickedEve } } } -} \ No newline at end of file +} diff --git a/Vault/Explorer/Dialogs/Secrets/SecretDialog.Designer.cs b/Vault/Explorer/Dialogs/Secrets/SecretDialog.Designer.cs index e42311d3..5f2e0539 100644 --- a/Vault/Explorer/Dialogs/Secrets/SecretDialog.Designer.cs +++ b/Vault/Explorer/Dialogs/Secrets/SecretDialog.Designer.cs @@ -34,6 +34,7 @@ private void InitializeComponent() this.uxLinkLabelViewCertificate = new System.Windows.Forms.LinkLabel(); this.uxPropertyGridSecret = new System.Windows.Forms.PropertyGrid(); this.uxLabelBytesLeft = new System.Windows.Forms.Label(); + this.uxButtonToggleMask = new System.Windows.Forms.Button(); this.uxTimerValueTypingCompleted = new System.Windows.Forms.Timer(this.components); this.uxMenuNewValue = new System.Windows.Forms.ContextMenuStrip(this.components); this.uxMenuItemNewPassword = new System.Windows.Forms.ToolStripMenuItem(); @@ -110,9 +111,21 @@ private void InitializeComponent() this.uxPropertyGridSecret.Size = new System.Drawing.Size(986, 132); this.uxPropertyGridSecret.TabIndex = 0; this.uxPropertyGridSecret.ToolbarVisible = false; - // + // + // uxButtonToggleMask + // + this.uxButtonToggleMask.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right; + this.uxButtonToggleMask.Location = new System.Drawing.Point(730, 61); + this.uxButtonToggleMask.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0); + this.uxButtonToggleMask.Name = "uxButtonToggleMask"; + this.uxButtonToggleMask.Size = new System.Drawing.Size(48, 22); + this.uxButtonToggleMask.TabIndex = 11; + this.uxButtonToggleMask.Text = "Hide"; + this.uxButtonToggleMask.UseVisualStyleBackColor = true; + this.uxButtonToggleMask.Click += this.uxButtonToggleMask_Click; + // // uxLabelBytesLeft - // + // this.uxLabelBytesLeft.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right; this.uxLabelBytesLeft.Location = new System.Drawing.Point(782, 64); this.uxLabelBytesLeft.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0); @@ -164,6 +177,7 @@ private void InitializeComponent() this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; this.ClientSize = new System.Drawing.Size(1018, 613); this.Controls.Add(this.uxSplitContainer); + this.Controls.Add(this.uxButtonToggleMask); this.Controls.Add(this.uxLabelBytesLeft); this.Controls.Add(tableLayoutPanel1); this.Name = "SecretDialog"; @@ -182,6 +196,7 @@ private void InitializeComponent() private System.Windows.Forms.PropertyGrid uxPropertyGridSecret; private System.Windows.Forms.SplitContainer uxSplitContainer; private System.Windows.Forms.Label uxLabelBytesLeft; + private System.Windows.Forms.Button uxButtonToggleMask; private System.Windows.Forms.Timer uxTimerValueTypingCompleted; private System.Windows.Forms.ContextMenuStrip uxMenuNewValue; private System.Windows.Forms.ToolStripMenuItem uxMenuItemNewPassword; diff --git a/Vault/Explorer/Dialogs/Secrets/SecretDialog.cs b/Vault/Explorer/Dialogs/Secrets/SecretDialog.cs index 976d486d..6a4113ba 100644 --- a/Vault/Explorer/Dialogs/Secrets/SecretDialog.cs +++ b/Vault/Explorer/Dialogs/Secrets/SecretDialog.cs @@ -12,7 +12,7 @@ namespace Microsoft.Vault.Explorer.Dialogs.Secrets using System.Security.Cryptography.X509Certificates; using System.Threading.Tasks; using System.Windows.Forms; - using Microsoft.Azure.KeyVault.Models; + using Azure.Security.KeyVault.Secrets; using Microsoft.Vault.Explorer.Controls; using Microsoft.Vault.Explorer.Controls.MenuItems; using Microsoft.Vault.Explorer.Dialogs.Passwords; @@ -27,10 +27,12 @@ namespace Microsoft.Vault.Explorer.Dialogs.Secrets using Settings = Microsoft.Vault.Explorer.Settings; using Utils = Microsoft.Vault.Explorer.Common.Utils; - public partial class SecretDialog : ItemDialogBase // + public partial class SecretDialog : ItemDialogBase // { private CertificateValueObject _certificateObj; private Scintilla uxTextBoxValue; + private bool _isMasked = false; + private string _maskedRealValue = string.Empty; private SecretDialog(ISession session, string title, ItemDialogBaseMode mode) : base(session, title, mode) { @@ -61,8 +63,12 @@ private SecretDialog(ISession session, string title, ItemDialogBaseMode mode) : public SecretDialog(ISession session) : this(session, "New secret", ItemDialogBaseMode.New) { this._changed = true; - var s = new SecretBundle { Attributes = new SecretAttributes(), ContentType = ContentTypeEnumConverter.GetDescription(ContentType.Text) }; - this.RefreshSecretObject(s); + this.PropertyObject = new PropertyObjectSecret(ContentTypeEnumConverter.GetDescription(ContentType.Text), this.SecretObject_PropertyChanged); + this.uxPropertyGridSecret.SelectedObject = this.PropertyObject; + this.uxTextBoxName.Text = this.PropertyObject.Name; + this.uxTextBoxValue.Text = this.PropertyObject.Value; + var obj = (PropertyObjectSecret)this.PropertyObject; + this.ToggleCertificateMode(obj.ContentType.IsCertificate()); SecretKind defaultSK = this.TryGetDefaultSecretKind(); int defaultIndex = this.uxMenuSecretKind.Items.IndexOf(defaultSK); this.uxMenuSecretKind.Items[defaultIndex].PerformClick(); @@ -95,10 +101,10 @@ public SecretDialog(ISession session, FileInfo fi) : this(session) break; case ContentType.KeyVaultSecret: var kvsf = Utils.LoadFromJsonFile(fi.FullName); - SecretBundle s = kvsf.Deserialize(); - this.uxPropertyGridSecret.SelectedObject = this.PropertyObject = new PropertyObjectSecret(s, this.SecretObject_PropertyChanged); - this.uxTextBoxName.Text = s.SecretIdentifier?.Name; - this.uxTextBoxValue.Text = s.Value; + var sfd = kvsf.Deserialize(); + this.uxPropertyGridSecret.SelectedObject = this.PropertyObject = new PropertyObjectSecret(sfd, this.SecretObject_PropertyChanged); + this.uxTextBoxName.Text = this.PropertyObject.Name; + this.uxTextBoxValue.Text = this.PropertyObject.Value; return; default: this.uxTextBoxValue.Text = File.ReadAllText(fi.FullName); @@ -131,11 +137,11 @@ public SecretDialog(ISession session, X509Certificate2 certificate) : this(sessi /// /// Edit or Copy secret /// - public SecretDialog(ISession session, string name, IEnumerable versions) : this(session, "Edit secret", ItemDialogBaseMode.Edit) + public SecretDialog(ISession session, string name, IEnumerable versions) : this(session, "Edit secret", ItemDialogBaseMode.Edit) { this.Text += $" {name}"; int i = 0; - this.uxMenuVersions.Items.AddRange((from v in versions orderby v.Attributes.Created descending select new SecretVersion(i++, v)).ToArray()); + this.uxMenuVersions.Items.AddRange((from v in versions orderby v.CreatedOn descending select new SecretVersion(i++, v)).ToArray()); this.uxMenuVersions_ItemClicked(null, new ToolStripItemClickedEventArgs(this.uxMenuVersions.Items[0])); // Pass sender as NULL so _changed will be set to false } @@ -186,7 +192,7 @@ private static List LoadSecretKinds(VaultAlias vaultAlias, out List< return orderedValidatedSecretKinds; } - private void RefreshSecretObject(SecretBundle s) + private void RefreshSecretObject(KeyVaultSecret s) { this.PropertyObject = new PropertyObjectSecret(s, this.SecretObject_PropertyChanged); this.uxPropertyGridSecret.SelectedObject = this.PropertyObject; @@ -300,12 +306,35 @@ private void RefreshCertificate(CertificateValueObject cvo) private void uxTextBoxValue_TextChanged(object sender, EventArgs e) { + if (this._isMasked) + { + return; // Value is masked; don't write bullet characters back to PropertyObject + } + this._changed = true; this.PropertyObject.Value = this.uxTextBoxValue.Text; this.uxTimerValueTypingCompleted.Stop(); // Wait for user to finish the typing in a text box this.uxTimerValueTypingCompleted.Start(); } + private void uxButtonToggleMask_Click(object sender, EventArgs e) + { + if (this._isMasked) + { + this._isMasked = false; + this.uxTextBoxValue.Text = this._maskedRealValue; + this._maskedRealValue = string.Empty; + this.uxButtonToggleMask.Text = "Hide"; + } + else + { + this._maskedRealValue = this.uxTextBoxValue.Text; + this._isMasked = true; + this.uxTextBoxValue.Text = new string('*', this._maskedRealValue.Length); + this.uxButtonToggleMask.Text = "Show"; + } + } + private void SecretObject_PropertyChanged(object sender, PropertyChangedEventArgs e) { this._changed = true; @@ -380,7 +409,7 @@ protected override void uxMenuSecretKind_ItemClicked(object sender, ToolStripIte protected override async Task OnVersionChangeAsync(CustomVersion cv) { SecretVersion sv = (SecretVersion)cv; - var s = await this._session.CurrentVault.GetSecretAsync(sv.SecretItem.Identifier.Name, sv.SecretItem.Identifier.Version); + var s = await this._session.CurrentVault.GetSecretAsync(sv.SecretItem.Name, sv.SecretItem.Version); this.RefreshSecretObject(s); this.AutoDetectSecretKind(); return s; diff --git a/Vault/Explorer/Dialogs/Settings/SettingsDialog.cs b/Vault/Explorer/Dialogs/Settings/SettingsDialog.cs index 96bbe561..b534a6bf 100644 --- a/Vault/Explorer/Dialogs/Settings/SettingsDialog.cs +++ b/Vault/Explorer/Dialogs/Settings/SettingsDialog.cs @@ -75,8 +75,8 @@ private string FetchVersions() StringBuilder sb = new StringBuilder(); sb.AppendLine(Utils.GetFileVersionString(string.Format("{0} version: ", Globals.AppName), Path.GetFileName(Application.ExecutablePath), string.Format(" ({0})", Environment.Is64BitProcess ? "x64" : "x86"))); sb.AppendLine(string.Format(".NET framework version: {0}", Environment.Version)); - sb.AppendLine(Utils.GetFileVersionString("Microsoft.Azure.KeyVault.dll version: ", "Microsoft.Azure.KeyVault.dll")); - sb.AppendLine(Utils.GetFileVersionString("Microsoft.Azure.Management.KeyVault.dll version: ", "Microsoft.Azure.Management.KeyVault.dll")); + sb.AppendLine(Utils.GetFileVersionString("Azure.Security.KeyVault.Secrets.dll version: ", "Azure.Security.KeyVault.Secrets.dll")); + sb.AppendLine(Utils.GetFileVersionString("Azure.ResourceManager.KeyVault.dll version: ", "Azure.ResourceManager.KeyVault.dll")); return sb.ToString(); } diff --git a/Vault/Explorer/Dialogs/Subscriptions/AccessPolicyEntryItem.cs b/Vault/Explorer/Dialogs/Subscriptions/AccessPolicyEntryItem.cs index ca91ef67..ff020618 100644 --- a/Vault/Explorer/Dialogs/Subscriptions/AccessPolicyEntryItem.cs +++ b/Vault/Explorer/Dialogs/Subscriptions/AccessPolicyEntryItem.cs @@ -3,16 +3,15 @@ namespace Microsoft.Vault.Explorer.Dialogs.Subscriptions using System; using System.ComponentModel; using System.Drawing.Design; - using Microsoft.Azure.Management.KeyVault.Models; + using Azure.ResourceManager.KeyVault.Models; using Newtonsoft.Json; [Editor(typeof(ExpandableObjectConverter), typeof(UITypeEditor))] public class AccessPolicyEntryItem { - private static readonly string[] EmptyList = new string[] { }; - private readonly AccessPolicyEntry _ape; + private readonly KeyVaultAccessPolicy _ape; - public AccessPolicyEntryItem(int index, AccessPolicyEntry ape) + public AccessPolicyEntryItem(int index, KeyVaultAccessPolicy ape) { this.Index = index; this._ape = ape; @@ -28,17 +27,17 @@ public AccessPolicyEntryItem(int index, AccessPolicyEntry ape) public Guid ObjectId => Guid.Parse(this._ape.ObjectId); [Description("Permissions to keys")] - public string PermissionsToKeys => string.Join(",", this._ape.Permissions.Keys ?? EmptyList); + public string PermissionsToKeys => string.Join(",", this._ape.Permissions?.Keys ?? Array.Empty()); [Description("Permissions to secrets")] - public string PermissionsToSecrets => string.Join(",", this._ape.Permissions.Secrets ?? EmptyList); + public string PermissionsToSecrets => string.Join(",", this._ape.Permissions?.Secrets ?? Array.Empty()); [Description("Permissions to certificates")] - public string PermissionsToCertificates => string.Join(",", this._ape.Permissions.Certificates ?? EmptyList); + public string PermissionsToCertificates => string.Join(",", this._ape.Permissions?.Certificates ?? Array.Empty()); [Description("Tenant ID of the principal")] public Guid TenantId => this._ape.TenantId; public override string ToString() => JsonConvert.SerializeObject(this, Formatting.Indented); } -} \ No newline at end of file +} diff --git a/Vault/Explorer/Dialogs/Subscriptions/AccountItem.cs b/Vault/Explorer/Dialogs/Subscriptions/AccountItem.cs index 7c23a25b..0ec69bbd 100644 --- a/Vault/Explorer/Dialogs/Subscriptions/AccountItem.cs +++ b/Vault/Explorer/Dialogs/Subscriptions/AccountItem.cs @@ -13,6 +13,6 @@ public AccountItem(string domainHint, string userAlias = null) this.UserAlias = userAlias ?? Globals.DefaultUserName; } - public override string ToString() => $"{this.UserAlias}@{this.DomainHint}"; + public override string ToString() => this.UserAlias.Contains("@") ? this.UserAlias : $"{this.UserAlias}@{this.DomainHint}"; } -} \ No newline at end of file +} diff --git a/Vault/Explorer/Dialogs/Subscriptions/ListViewItemVault.cs b/Vault/Explorer/Dialogs/Subscriptions/ListViewItemVault.cs index 15a9e1a2..7559a092 100644 --- a/Vault/Explorer/Dialogs/Subscriptions/ListViewItemVault.cs +++ b/Vault/Explorer/Dialogs/Subscriptions/ListViewItemVault.cs @@ -2,23 +2,24 @@ namespace Microsoft.Vault.Explorer.Dialogs.Subscriptions { using System.Text.RegularExpressions; using System.Windows.Forms; + using Azure.ResourceManager.KeyVault; public class ListViewItemVault : ListViewItem { // https://azure.microsoft.com/en-us/documentation/articles/guidance-naming-conventions/ private static readonly Regex s_resourceNameRegex = new Regex(@".*\/resourceGroups\/(?[a-zA-Z0-9_\-\.]{1,64})\/", RegexOptions.CultureInvariant | RegexOptions.Compiled); - public readonly Azure.Management.KeyVault.Models.Resource Vault; + public readonly KeyVaultResource Vault; public readonly string GroupName; - public ListViewItemVault(Azure.Management.KeyVault.Models.Resource vault) : base(vault.Name) + public ListViewItemVault(KeyVaultResource vault) : base(vault.Data.Name) { this.Vault = vault; - this.Name = vault.Name; - this.GroupName = s_resourceNameRegex.Match(vault.Id).Groups["GroupName"].Value; + this.Name = vault.Data.Name; + this.GroupName = s_resourceNameRegex.Match(vault.Id.ToString()).Groups["GroupName"].Value; this.SubItems.Add(this.GroupName); - this.ToolTipText = $"Location: {vault.Location}"; + this.ToolTipText = $"Location: {vault.Data.Location}"; this.ImageIndex = 1; } } -} \ No newline at end of file +} diff --git a/Vault/Explorer/Dialogs/Subscriptions/PropertyObjectVault.cs b/Vault/Explorer/Dialogs/Subscriptions/PropertyObjectVault.cs index 3d163ce8..6521f652 100644 --- a/Vault/Explorer/Dialogs/Subscriptions/PropertyObjectVault.cs +++ b/Vault/Explorer/Dialogs/Subscriptions/PropertyObjectVault.cs @@ -2,24 +2,24 @@ namespace Microsoft.Vault.Explorer.Dialogs.Subscriptions { using System; using System.ComponentModel; - using Microsoft.Azure.Management.KeyVault.Models; + using Azure.ResourceManager.KeyVault; using Microsoft.Vault.Explorer.Model.Collections; public class PropertyObjectVault { private readonly Subscription _subscription; private readonly string _resourceGroupName; - private readonly Vault _vault; + private readonly KeyVaultResource _vault; - public PropertyObjectVault(Subscription s, string resourceGroupName, Vault vault) + public PropertyObjectVault(Subscription s, string resourceGroupName, KeyVaultResource vault) { this._subscription = s; this._resourceGroupName = resourceGroupName; this._vault = vault; this.Tags = new ObservableTagItemsCollection(); - if (null != this._vault.Tags) + if (null != this._vault.Data.Tags) { - foreach (var kvp in this._vault.Tags) + foreach (var kvp in this._vault.Data.Tags) { this.Tags.Add(new TagItem(kvp)); } @@ -27,7 +27,7 @@ public PropertyObjectVault(Subscription s, string resourceGroupName, Vault vault this.AccessPolicies = new ObservableAccessPoliciesCollection(); int i = -1; - foreach (var ape in this._vault.Properties.AccessPolicies) + foreach (var ape in this._vault.Data.Properties.AccessPolicies) { this.AccessPolicies.Add(new AccessPolicyEntryItem(++i, ape)); } @@ -35,15 +35,15 @@ public PropertyObjectVault(Subscription s, string resourceGroupName, Vault vault [DisplayName("Name")] [ReadOnly(true)] - public string Name => this._vault.Name; + public string Name => this._vault.Data.Name; [DisplayName("Location")] [ReadOnly(true)] - public string Location => this._vault.Location; + public string Location => this._vault.Data.Location.ToString(); [DisplayName("Uri")] [ReadOnly(true)] - public string Uri => this._vault.Properties.VaultUri; + public string Uri => this._vault.Data.Properties.VaultUri?.ToString(); [DisplayName("Subscription Name")] [ReadOnly(true)] @@ -63,11 +63,11 @@ public PropertyObjectVault(Subscription s, string resourceGroupName, Vault vault [DisplayName("Sku")] [ReadOnly(true)] - public SkuName Sku => this._vault.Properties.Sku.Name; + public string Sku => this._vault.Data.Properties.Sku.Name.ToString(); [DisplayName("Access Policies")] [ReadOnly(true)] [TypeConverter(typeof(ExpandableCollectionObjectConverter))] public ObservableAccessPoliciesCollection AccessPolicies { get; } } -} \ No newline at end of file +} diff --git a/Vault/Explorer/Dialogs/Subscriptions/StaticTokenCredential.cs b/Vault/Explorer/Dialogs/Subscriptions/StaticTokenCredential.cs new file mode 100644 index 00000000..91a257d0 --- /dev/null +++ b/Vault/Explorer/Dialogs/Subscriptions/StaticTokenCredential.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +namespace Microsoft.Vault.Explorer.Dialogs.Subscriptions +{ + using System; + using System.Threading; + using System.Threading.Tasks; + using Azure.Core; + + /// + /// A that wraps a pre-acquired access token for use + /// with ARM clients in the subscriptions manager dialog. + /// + internal sealed class StaticTokenCredential : TokenCredential + { + private readonly AccessToken _token; + + internal StaticTokenCredential(string accessToken, DateTimeOffset expiresOn) + { + _token = new AccessToken(accessToken, expiresOn); + } + + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + => _token; + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + => new ValueTask(_token); + } +} diff --git a/Vault/Explorer/Dialogs/Subscriptions/SubscriptionsManagerDialog.Designer.cs b/Vault/Explorer/Dialogs/Subscriptions/SubscriptionsManagerDialog.Designer.cs index c016ff44..d39e3adf 100644 --- a/Vault/Explorer/Dialogs/Subscriptions/SubscriptionsManagerDialog.Designer.cs +++ b/Vault/Explorer/Dialogs/Subscriptions/SubscriptionsManagerDialog.Designer.cs @@ -34,6 +34,7 @@ private void InitializeComponent() System.Windows.Forms.ColumnHeader columnHeader1; System.Windows.Forms.ColumnHeader columnHeader2; System.Windows.Forms.ToolStripLabel toolStripLabel1; + System.Windows.Forms.ToolStripLabel toolStripLabel2; System.Windows.Forms.SplitContainer splitContainer1; System.Windows.Forms.SplitContainer splitContainer2; System.Windows.Forms.ImageList imageList1; @@ -43,6 +44,7 @@ private void InitializeComponent() this.uxListViewVaults = new System.Windows.Forms.ListView(); this.uxPropertyGridVault = new System.Windows.Forms.PropertyGrid(); this.uxComboBoxAccounts = new System.Windows.Forms.ToolStripComboBox(); + this.uxComboBoxTenants = new System.Windows.Forms.ToolStripComboBox(); this.uxButtonCancelOperation = new System.Windows.Forms.ToolStripButton(); this.uxProgressBar = new System.Windows.Forms.ToolStripProgressBar(); this.uxStatusLabel = new System.Windows.Forms.ToolStripLabel(); @@ -53,6 +55,7 @@ private void InitializeComponent() columnHeader1 = ((System.Windows.Forms.ColumnHeader)(new System.Windows.Forms.ColumnHeader())); columnHeader2 = ((System.Windows.Forms.ColumnHeader)(new System.Windows.Forms.ColumnHeader())); toolStripLabel1 = new System.Windows.Forms.ToolStripLabel(); + toolStripLabel2 = new System.Windows.Forms.ToolStripLabel(); splitContainer1 = new System.Windows.Forms.SplitContainer(); splitContainer2 = new System.Windows.Forms.SplitContainer(); imageList1 = new System.Windows.Forms.ImageList(this.components); @@ -94,6 +97,12 @@ private void InitializeComponent() toolStripLabel1.Size = new System.Drawing.Size(63, 25); toolStripLabel1.Text = "Account"; // + // toolStripLabel2 + // + toolStripLabel2.Name = "toolStripLabel2"; + toolStripLabel2.Size = new System.Drawing.Size(51, 25); + toolStripLabel2.Text = "Tenant"; + // // splitContainer1 // splitContainer1.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) @@ -204,6 +213,8 @@ private void InitializeComponent() toolStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { toolStripLabel1, this.uxComboBoxAccounts, + toolStripLabel2, + this.uxComboBoxTenants, this.uxButtonCancelOperation, this.uxProgressBar, this.uxStatusLabel}); @@ -221,6 +232,14 @@ private void InitializeComponent() this.uxComboBoxAccounts.Size = new System.Drawing.Size(351, 28); this.uxComboBoxAccounts.SelectedIndexChanged += new System.EventHandler(this.uxComboBoxAccounts_SelectedIndexChanged); // + // uxComboBoxTenants + // + this.uxComboBoxTenants.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; + this.uxComboBoxTenants.DropDownWidth = 260; + this.uxComboBoxTenants.Name = "uxComboBoxTenants"; + this.uxComboBoxTenants.Size = new System.Drawing.Size(260, 28); + this.uxComboBoxTenants.SelectedIndexChanged += new System.EventHandler(this.uxComboBoxTenants_SelectedIndexChanged); + // // uxButtonCancelOperation // this.uxButtonCancelOperation.Alignment = System.Windows.Forms.ToolStripItemAlignment.Right; @@ -282,6 +301,7 @@ private void InitializeComponent() this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; this.CancelButton = this.uxButtonCancel; this.ClientSize = new System.Drawing.Size(829, 759); + this.MinimumSize = new System.Drawing.Size(640, 680); this.Controls.Add(splitContainer1); this.Controls.Add(toolStrip1); this.Controls.Add(this.uxButtonCancel); @@ -315,6 +335,7 @@ private void InitializeComponent() private System.Windows.Forms.Button uxButtonCancel; private System.Windows.Forms.Button uxButtonOK; private System.Windows.Forms.ToolStripComboBox uxComboBoxAccounts; + private System.Windows.Forms.ToolStripComboBox uxComboBoxTenants; private System.Windows.Forms.ToolStripProgressBar uxProgressBar; private System.Windows.Forms.ToolStripButton uxButtonCancelOperation; private System.Windows.Forms.ToolStripLabel uxStatusLabel; diff --git a/Vault/Explorer/Dialogs/Subscriptions/SubscriptionsManagerDialog.cs b/Vault/Explorer/Dialogs/Subscriptions/SubscriptionsManagerDialog.cs index 630e9d1a..55bb72db 100644 --- a/Vault/Explorer/Dialogs/Subscriptions/SubscriptionsManagerDialog.cs +++ b/Vault/Explorer/Dialogs/Subscriptions/SubscriptionsManagerDialog.cs @@ -4,16 +4,19 @@ namespace Microsoft.Vault.Explorer.Dialogs.Subscriptions { using System; + using System.Collections.Generic; + using System.Linq; using System.Net.Http; using System.Net.Http.Headers; using System.Threading.Tasks; using System.Windows.Forms; - using Microsoft.Azure.Management.KeyVault; + using Azure.ResourceManager; + using Azure.ResourceManager.KeyVault; using Microsoft.Identity.Client; - using Microsoft.Rest; using Microsoft.Vault.Explorer.Common; using Microsoft.Vault.Explorer.Model.Files.Aliases; using Microsoft.Vault.Library; + using Newtonsoft.Json.Linq; using Newtonsoft.Json; using Settings = Microsoft.Vault.Explorer.Settings; using Utils = Microsoft.Vault.Explorer.Common.Utils; @@ -22,12 +25,17 @@ public partial class SubscriptionsManagerDialog : Form { private const string ApiVersion = "api-version=2016-07-01"; private const string ManagmentEndpoint = "https://management.azure.com/"; + private const string TenantsApiVersion = "api-version=2020-01-01"; private const string AddAccountText = "Add New Account"; + private const string SelectAccountPrompt = "Select an account or add new..."; + private const string SelectTenantPrompt = "Select a tenant..."; private AccountItem _currentAccountItem; private AuthenticationResult _currentAuthResult; - private KeyVaultManagementClient _currentKeyVaultMgmtClient; + private ArmClient _currentArmClient; private readonly HttpClient _httpClient; + private bool _suppressTenantSelectionEvent; + private bool _showOnboardingOnLoad; public VaultAlias CurrentVaultAlias { get; private set; } @@ -51,6 +59,9 @@ public SubscriptionsManagerDialog() } this.uxComboBoxAccounts.Items.Add(AddAccountText); + this.uxComboBoxTenants.Items.Clear(); + this.uxComboBoxTenants.Items.Add(SelectTenantPrompt); + this.uxComboBoxTenants.SelectedIndex = 0; // Only auto-select if we have pre-configured accounts, otherwise let user choose if (hasPreConfiguredAccounts) @@ -59,9 +70,27 @@ public SubscriptionsManagerDialog() } else { - // No pre-configured accounts, don't auto-select anything + // No pre-configured accounts — defer the onboarding prompt until after the form is shown + // so the form renders fully before any MessageBox appears. this.uxComboBoxAccounts.SelectedIndex = -1; - this.uxComboBoxAccounts.Text = "Select an account or add new..."; + this.uxComboBoxAccounts.Text = SelectAccountPrompt; + this._showOnboardingOnLoad = true; + } + + this.Shown += this.SubscriptionsManagerDialog_Shown; + } + + private void SubscriptionsManagerDialog_Shown(object sender, EventArgs e) + { + if (this._showOnboardingOnLoad) + { + this._showOnboardingOnLoad = false; + MessageBox.Show( + this, + "No saved accounts were found.\n\nSelect 'Add New Account' to sign in, then choose a subscription and vault.", + "Subscriptions onboarding", + MessageBoxButtons.OK, + MessageBoxIcon.Information); } } @@ -84,7 +113,12 @@ private async void uxComboBoxAccounts_SelectedIndexChanged(object sender, EventA await this.GetAuthenticationTokenAsync(); if (this._currentAuthResult.Account != null) { - this._currentAccountItem.UserAlias = this._currentAuthResult.Account.Username.Split('@')[0]; + this._currentAccountItem.UserAlias = this._currentAuthResult.Account.Username; + } + + if (!string.IsNullOrWhiteSpace(this._currentAuthResult?.TenantId)) + { + this._currentAccountItem.DomainHint = this._currentAuthResult.TenantId; } break; @@ -98,23 +132,116 @@ private async void uxComboBoxAccounts_SelectedIndexChanged(object sender, EventA return; } - using (var op = this.NewUxOperationWithProgress(this.uxComboBoxAccounts)) + await this.LoadTenantsAsync(); + await this.LoadSubscriptionsAsync(); + } + + private async Task LoadTenantsAsync() + { + using (var op = this.NewUxOperationWithProgress(this.uxComboBoxAccounts, this.uxComboBoxTenants)) { - this._httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", this._currentAuthResult.AccessToken); - var hrm = await this._httpClient.GetAsync($"{ManagmentEndpoint}subscriptions?{ApiVersion}", op.CancellationToken); - var json = await hrm.Content.ReadAsStringAsync(); - var subs = JsonConvert.DeserializeObject(json); + try + { + this._httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", this._currentAuthResult.AccessToken); + var hrm = await this._httpClient.GetAsync($"{ManagmentEndpoint}tenants?{TenantsApiVersion}", op.CancellationToken); + var json = await hrm.Content.ReadAsStringAsync(); + hrm.EnsureSuccessStatusCode(); + JObject payload = JObject.Parse(json); + List tenants = payload["value"]? + .Select(v => new TenantItem((string)v["tenantId"], (string)v["displayName"], (string)v["defaultDomain"])) + .Where(t => !string.IsNullOrWhiteSpace(t.TenantId)) + .ToList() ?? new List(); - this.uxListViewSubscriptions.Items.Clear(); - this.uxListViewVaults.Items.Clear(); - this.uxPropertyGridVault.SelectedObject = null; - foreach (var s in subs.Subscriptions) + if (tenants.Count == 0) + { + return; + } + + this._suppressTenantSelectionEvent = true; + this.uxComboBoxTenants.Items.Clear(); + foreach (TenantItem tenant in tenants) + { + this.uxComboBoxTenants.Items.Add(tenant); + } + + TenantItem selectedTenant = tenants.FirstOrDefault(t => string.Equals(t.TenantId, this._currentAuthResult.TenantId, StringComparison.OrdinalIgnoreCase)) + ?? tenants.FirstOrDefault(t => string.Equals(t.TenantId, this._currentAccountItem.DomainHint, StringComparison.OrdinalIgnoreCase)) + ?? tenants[0]; + this.uxComboBoxTenants.SelectedItem = selectedTenant; + } + catch (Exception ex) + { + MessageBox.Show( + $"Failed to load tenants: {ex.Message}", + "Tenant load error", + MessageBoxButtons.OK, + MessageBoxIcon.Error); + } + finally + { + this._suppressTenantSelectionEvent = false; + } + } + } + + private async Task LoadSubscriptionsAsync() + { + using (var op = this.NewUxOperationWithProgress(this.uxComboBoxAccounts, this.uxComboBoxTenants)) + { + try + { + this._httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", this._currentAuthResult.AccessToken); + var hrm = await this._httpClient.GetAsync($"{ManagmentEndpoint}subscriptions?{ApiVersion}", op.CancellationToken); + var json = await hrm.Content.ReadAsStringAsync(); + hrm.EnsureSuccessStatusCode(); + var subs = JsonConvert.DeserializeObject(json); + + this.uxListViewSubscriptions.Items.Clear(); + this.uxListViewVaults.Items.Clear(); + this.uxPropertyGridVault.SelectedObject = null; + if (subs?.Subscriptions == null || subs.Subscriptions.Length == 0) + { + MessageBox.Show( + "No subscriptions were found for this account.\n\nConfirm the account has access and try another account or tenant.", + "No subscriptions found", + MessageBoxButtons.OK, + MessageBoxIcon.Warning); + return; + } + + foreach (var s in subs.Subscriptions) + { + this.uxListViewSubscriptions.Items.Add(new ListViewItemSubscription(s)); + } + } + catch (Exception ex) { - this.uxListViewSubscriptions.Items.Add(new ListViewItemSubscription(s)); + MessageBox.Show( + $"Failed to load subscriptions: {ex.Message}", + "Subscriptions error", + MessageBoxButtons.OK, + MessageBoxIcon.Error); } } } + private async void uxComboBoxTenants_SelectedIndexChanged(object sender, EventArgs e) + { + if (this._suppressTenantSelectionEvent || this.uxComboBoxTenants.SelectedItem is not TenantItem tenant) + { + return; + } + + if (this._currentAccountItem == null || string.Equals(this._currentAccountItem.DomainHint, tenant.TenantId, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + this._currentAccountItem.DomainHint = tenant.TenantId; + await this.GetAuthenticationTokenAsync(); + await this.LoadSubscriptionsAsync(); + } + private async void uxListViewSubscriptions_SelectedIndexChanged(object sender, EventArgs e) { ListViewItemSubscription s = this.uxListViewSubscriptions.SelectedItems.Count > 0 ? (ListViewItemSubscription)this.uxListViewSubscriptions.SelectedItems[0] : null; @@ -123,13 +250,20 @@ private async void uxListViewSubscriptions_SelectedIndexChanged(object sender, E return; } + if (this._currentAuthResult == null) + { + MessageBox.Show("Please sign in first by selecting an account.", "Not signed in", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + using (var op = this.NewUxOperationWithProgress(this.uxComboBoxAccounts)) { - var tvcc = new TokenCredentials(this._currentAuthResult.AccessToken); - this._currentKeyVaultMgmtClient = new KeyVaultManagementClient(tvcc) { SubscriptionId = s.Subscription.SubscriptionId.ToString() }; - var vaults = await this._currentKeyVaultMgmtClient.Vaults.ListAsync(null, op.CancellationToken); + var cred = new StaticTokenCredential(this._currentAuthResult.AccessToken, this._currentAuthResult.ExpiresOn); + this._currentArmClient = new ArmClient(cred, s.Subscription.SubscriptionId.ToString()); + var subscriptionResource = this._currentArmClient.GetSubscriptionResource( + SubscriptionResource.CreateResourceIdentifier(s.Subscription.SubscriptionId.ToString())); this.uxListViewVaults.Items.Clear(); - foreach (var v in vaults) + await foreach (var v in subscriptionResource.GetKeyVaultsAsync(cancellationToken: op.CancellationToken)) { this.uxListViewVaults.Items.Add(new ListViewItemVault(v)); } @@ -141,6 +275,7 @@ private async void uxListViewVaults_SelectedIndexChanged(object sender, EventArg ListViewItemSubscription s = this.uxListViewSubscriptions.SelectedItems.Count > 0 ? (ListViewItemSubscription)this.uxListViewSubscriptions.SelectedItems[0] : null; ListViewItemVault v = this.uxListViewVaults.SelectedItems.Count > 0 ? (ListViewItemVault)this.uxListViewVaults.SelectedItems[0] : null; this.uxButtonOK.Enabled = false; + this.CurrentVaultAlias = null; if (null == s || null == v) { return; @@ -148,16 +283,28 @@ private async void uxListViewVaults_SelectedIndexChanged(object sender, EventArg using (var op = this.NewUxOperationWithProgress(this.uxComboBoxAccounts)) { - var vault = await this._currentKeyVaultMgmtClient.Vaults.GetAsync(v.GroupName, v.Name); - this.uxPropertyGridVault.SelectedObject = new PropertyObjectVault(s.Subscription, v.GroupName, vault); - this.uxButtonOK.Enabled = true; - - this.CurrentVaultAlias = new VaultAlias(v.Name, new[] { v.Name }, new[] { "Custom" }) + try { - DomainHint = this._currentAccountItem.DomainHint, - UserAlias = this._currentAccountItem.UserAlias, - IsNew = true, // Mark as new since it's being added from SubscriptionsManagerDialog - }; + var vault = (await v.Vault.GetAsync(op.CancellationToken)).Value; + this.uxPropertyGridVault.SelectedObject = new PropertyObjectVault(s.Subscription, v.GroupName, vault); + this.CurrentVaultAlias = new VaultAlias(v.Name, new[] { v.Name }, new[] { "Custom" }) + { + DomainHint = this._currentAccountItem.DomainHint, + UserAlias = this._currentAccountItem.UserAlias, + IsNew = true, + }; + this.uxButtonOK.Enabled = true; + } + catch (Exception ex) + { + this.uxPropertyGridVault.SelectedObject = null; + MessageBox.Show( + this, + $"Failed to load vault details: {ex.Message}\n\nSelect a different vault or try again.", + "Vault load error", + MessageBoxButtons.OK, + MessageBoxIcon.Warning); + } } } @@ -179,11 +326,24 @@ private async void AddNewAccount() // Get new user account and add it to default settings string userAccountName = this._currentAuthResult.Account.Username; string[] userLogin = userAccountName.Split('@'); - this._currentAccountItem.UserAlias = userLogin[0]; - this._currentAccountItem.DomainHint = userLogin[1]; + if (userLogin.Length != 2) + { + MessageBox.Show("Could not parse signed-in account name. Please sign in with a standard UPN account.", "Unsupported account format", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + this._currentAccountItem.UserAlias = userAccountName; + this._currentAccountItem.DomainHint = string.IsNullOrWhiteSpace(this._currentAuthResult.TenantId) ? userLogin[1] : this._currentAuthResult.TenantId; if (!Settings.Default.AddUserAccountName(userAccountName)) { - MessageBox.Show($"The user name {userAccountName} already exists.", "Username exists", MessageBoxButtons.OK, MessageBoxIcon.Warning); + AccountItem existing = this.uxComboBoxAccounts.Items.OfType() + .FirstOrDefault(a => string.Equals(a.ToString(), userAccountName, StringComparison.OrdinalIgnoreCase)); + if (existing != null) + { + this.uxComboBoxAccounts.SelectedItem = existing; + } + + MessageBox.Show($"The account {userAccountName} is already configured and has been selected.", "Account already exists", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } @@ -192,12 +352,23 @@ private async void AddNewAccount() this.uxComboBoxAccounts.Items.Insert(0, newAccountItem); this.uxComboBoxAccounts.SelectedIndex = 0; } + catch (MsalException ex) + { + MessageBox.Show( + $"Authentication failed: {ex.Message}\n\nTip: close existing browser sign-in windows and try 'Add New Account' again.", + "Authentication Error", + MessageBoxButtons.OK, + MessageBoxIcon.Error); + // Reset selection to allow user to try again + this.uxComboBoxAccounts.SelectedIndex = -1; + this.uxComboBoxAccounts.Text = SelectAccountPrompt; + } catch (Exception ex) { MessageBox.Show($"Authentication failed: {ex.Message}", "Authentication Error", MessageBoxButtons.OK, MessageBoxIcon.Error); // Reset selection to allow user to try again this.uxComboBoxAccounts.SelectedIndex = -1; - this.uxComboBoxAccounts.Text = "Select an account or add new..."; + this.uxComboBoxAccounts.Text = SelectAccountPrompt; } } @@ -208,5 +379,25 @@ private async Task GetAuthenticationTokenAsync() string[] scopes = VaultAccess.ConvertResourceToScopes(ManagmentEndpoint); this._currentAuthResult = await vaui.AcquireTokenAsync(scopes, this._currentAccountItem.UserAlias); } + + private sealed class TenantItem + { + public string TenantId { get; } + public string DisplayName { get; } + public string Domain { get; } + + public TenantItem(string tenantId, string displayName, string domain) + { + this.TenantId = tenantId; + this.DisplayName = displayName; + this.Domain = domain; + } + + public override string ToString() + { + string name = !string.IsNullOrWhiteSpace(this.DisplayName) ? this.DisplayName : this.Domain; + return string.IsNullOrWhiteSpace(name) ? this.TenantId : $"{name} ({this.TenantId})"; + } + } } -} \ No newline at end of file +} diff --git a/Vault/Explorer/Globals.cs b/Vault/Explorer/Globals.cs index 90007c71..33762124 100644 --- a/Vault/Explorer/Globals.cs +++ b/Vault/Explorer/Globals.cs @@ -15,4 +15,4 @@ internal static class Globals public static string DefaultUserName = Environment.UserName; } -} \ No newline at end of file +} diff --git a/Vault/Explorer/MainForm.Designer.cs b/Vault/Explorer/MainForm.Designer.cs index 7ad82b59..54efb534 100644 --- a/Vault/Explorer/MainForm.Designer.cs +++ b/Vault/Explorer/MainForm.Designer.cs @@ -200,6 +200,14 @@ private void InitializeComponent() this.uxButtonCopyLink = new System.Windows.Forms.ToolStripMenuItem(); this.uxButtonSave = new System.Windows.Forms.ToolStripMenuItem(); this.uxButtonExportToTsv = new System.Windows.Forms.ToolStripMenuItem(); + this.uxButtonCopyAsEnvVar = new System.Windows.Forms.ToolStripMenuItem(); + this.uxButtonCopyAsDockerEnv = new System.Windows.Forms.ToolStripMenuItem(); + this.uxButtonCopyAsK8sYaml = new System.Windows.Forms.ToolStripMenuItem(); + this.uxButtonCopyName = new System.Windows.Forms.ToolStripMenuItem(); + this.uxMenuItemCopyAsEnvVar = new System.Windows.Forms.ToolStripMenuItem(); + this.uxMenuItemCopyAsDockerEnv = new System.Windows.Forms.ToolStripMenuItem(); + this.uxMenuItemCopyAsK8sYaml = new System.Windows.Forms.ToolStripMenuItem(); + this.uxMenuItemCopyName = new System.Windows.Forms.ToolStripMenuItem(); this.uxButtonFavorite = new System.Windows.Forms.ToolStripButton(); this.uxButtonSettings = new System.Windows.Forms.ToolStripButton(); this.uxButtonHelp = new System.Windows.Forms.ToolStripButton(); @@ -222,6 +230,8 @@ private void InitializeComponent() toolStripSeparator3 = new System.Windows.Forms.ToolStripSeparator(); usStatusLabelSpring = new System.Windows.Forms.ToolStripStatusLabel(); toolStripMenuItem1 = new System.Windows.Forms.ToolStripSeparator(); + System.Windows.Forms.ToolStripSeparator toolStripSeparatorCopyAs1 = new System.Windows.Forms.ToolStripSeparator(); + System.Windows.Forms.ToolStripSeparator toolStripSeparatorCopyAs2 = new System.Windows.Forms.ToolStripSeparator(); ((System.ComponentModel.ISupportInitialize)splitContainer1).BeginInit(); splitContainer1.Panel1.SuspendLayout(); splitContainer1.Panel2.SuspendLayout(); @@ -663,7 +673,7 @@ private void InitializeComponent() // // uxMenuItemShare // - this.uxMenuItemShare.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { this.uxMenuItemCopy, this.uxMenuItemCopyLink, this.uxMenuItemSave, toolStripMenuItem2, this.uxMenuItemExportToTsv }); + this.uxMenuItemShare.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { this.uxMenuItemCopy, this.uxMenuItemCopyLink, this.uxMenuItemSave, toolStripMenuItem2, this.uxMenuItemExportToTsv, toolStripSeparatorCopyAs2, this.uxMenuItemCopyAsEnvVar, this.uxMenuItemCopyAsDockerEnv, this.uxMenuItemCopyAsK8sYaml, this.uxMenuItemCopyName }); this.uxMenuItemShare.Enabled = false; this.uxMenuItemShare.Image = Properties.Resources.group; this.uxMenuItemShare.Name = "uxMenuItemShare"; @@ -712,7 +722,44 @@ private void InitializeComponent() this.uxMenuItemExportToTsv.Text = "&Export to Tsv..."; this.uxMenuItemExportToTsv.ToolTipText = "Export all or selected items to .tsv file"; this.uxMenuItemExportToTsv.Click += this.uxButtonExportToTsv_Click; - // + // + // uxMenuItemCopyAsEnvVar + // + this.uxMenuItemCopyAsEnvVar.Image = Properties.Resources.page_copy; + this.uxMenuItemCopyAsEnvVar.Name = "uxMenuItemCopyAsEnvVar"; + this.uxMenuItemCopyAsEnvVar.Size = new System.Drawing.Size(251, 22); + this.uxMenuItemCopyAsEnvVar.Text = "Copy as &env var"; + this.uxMenuItemCopyAsEnvVar.ToolTipText = "Copy as NAME=value env var"; + this.uxMenuItemCopyAsEnvVar.Click += this.uxButtonCopyAsEnvVar_Click; + // + // uxMenuItemCopyAsDockerEnv + // + this.uxMenuItemCopyAsDockerEnv.Image = Properties.Resources.page_copy; + this.uxMenuItemCopyAsDockerEnv.Name = "uxMenuItemCopyAsDockerEnv"; + this.uxMenuItemCopyAsDockerEnv.Size = new System.Drawing.Size(251, 22); + this.uxMenuItemCopyAsDockerEnv.Text = "Copy as &Docker --env"; + this.uxMenuItemCopyAsDockerEnv.ToolTipText = "Copy as --env NAME=value for docker run"; + this.uxMenuItemCopyAsDockerEnv.Click += this.uxButtonCopyAsDockerEnv_Click; + // + // uxMenuItemCopyAsK8sYaml + // + this.uxMenuItemCopyAsK8sYaml.Image = Properties.Resources.page_copy; + this.uxMenuItemCopyAsK8sYaml.Name = "uxMenuItemCopyAsK8sYaml"; + this.uxMenuItemCopyAsK8sYaml.Size = new System.Drawing.Size(251, 22); + this.uxMenuItemCopyAsK8sYaml.Text = "Copy as &Kubernetes secret YAML"; + this.uxMenuItemCopyAsK8sYaml.ToolTipText = "Copy as Kubernetes stringData secret YAML"; + this.uxMenuItemCopyAsK8sYaml.Click += this.uxButtonCopyAsK8sYaml_Click; + // + // uxMenuItemCopyName + // + this.uxMenuItemCopyName.Image = Properties.Resources.page_copy; + this.uxMenuItemCopyName.Name = "uxMenuItemCopyName"; + this.uxMenuItemCopyName.ShortcutKeys = System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.Shift | System.Windows.Forms.Keys.N; + this.uxMenuItemCopyName.Size = new System.Drawing.Size(251, 22); + this.uxMenuItemCopyName.Text = "Copy &name only"; + this.uxMenuItemCopyName.ToolTipText = "Copy secret name to clipboard (no value)"; + this.uxMenuItemCopyName.Click += this.uxButtonCopyName_Click; + // // uxMenuItemFavorite // this.uxMenuItemFavorite.Enabled = false; @@ -954,7 +1001,7 @@ private void InitializeComponent() // // uxButtonShare // - this.uxButtonShare.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { this.uxButtonCopy, this.uxButtonCopyLink, this.uxButtonSave, toolStripMenuItem1, this.uxButtonExportToTsv }); + this.uxButtonShare.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { this.uxButtonCopy, this.uxButtonCopyLink, this.uxButtonSave, toolStripMenuItem1, this.uxButtonExportToTsv, toolStripSeparatorCopyAs1, this.uxButtonCopyAsEnvVar, this.uxButtonCopyAsDockerEnv, this.uxButtonCopyAsK8sYaml, this.uxButtonCopyName }); this.uxButtonShare.Enabled = false; this.uxButtonShare.Image = Properties.Resources.group; this.uxButtonShare.ImageTransparentColor = System.Drawing.Color.Magenta; @@ -1000,7 +1047,43 @@ private void InitializeComponent() this.uxButtonExportToTsv.Text = "&Export to Tsv..."; this.uxButtonExportToTsv.ToolTipText = "Export all or selected items to .tsv file"; this.uxButtonExportToTsv.Click += this.uxButtonExportToTsv_Click; - // + // + // uxButtonCopyAsEnvVar + // + this.uxButtonCopyAsEnvVar.Image = Properties.Resources.page_copy; + this.uxButtonCopyAsEnvVar.Name = "uxButtonCopyAsEnvVar"; + this.uxButtonCopyAsEnvVar.Size = new System.Drawing.Size(220, 22); + this.uxButtonCopyAsEnvVar.Text = "Copy as &env var"; + this.uxButtonCopyAsEnvVar.ToolTipText = "Copy as NAME=value env var"; + this.uxButtonCopyAsEnvVar.Click += this.uxButtonCopyAsEnvVar_Click; + // + // uxButtonCopyAsDockerEnv + // + this.uxButtonCopyAsDockerEnv.Image = Properties.Resources.page_copy; + this.uxButtonCopyAsDockerEnv.Name = "uxButtonCopyAsDockerEnv"; + this.uxButtonCopyAsDockerEnv.Size = new System.Drawing.Size(220, 22); + this.uxButtonCopyAsDockerEnv.Text = "Copy as &Docker --env"; + this.uxButtonCopyAsDockerEnv.ToolTipText = "Copy as --env NAME=value for docker run"; + this.uxButtonCopyAsDockerEnv.Click += this.uxButtonCopyAsDockerEnv_Click; + // + // uxButtonCopyAsK8sYaml + // + this.uxButtonCopyAsK8sYaml.Image = Properties.Resources.page_copy; + this.uxButtonCopyAsK8sYaml.Name = "uxButtonCopyAsK8sYaml"; + this.uxButtonCopyAsK8sYaml.Size = new System.Drawing.Size(220, 22); + this.uxButtonCopyAsK8sYaml.Text = "Copy as &Kubernetes secret YAML"; + this.uxButtonCopyAsK8sYaml.ToolTipText = "Copy as Kubernetes stringData secret YAML"; + this.uxButtonCopyAsK8sYaml.Click += this.uxButtonCopyAsK8sYaml_Click; + // + // uxButtonCopyName + // + this.uxButtonCopyName.Image = Properties.Resources.page_copy; + this.uxButtonCopyName.Name = "uxButtonCopyName"; + this.uxButtonCopyName.Size = new System.Drawing.Size(220, 22); + this.uxButtonCopyName.Text = "Copy &name only"; + this.uxButtonCopyName.ToolTipText = "Copy secret name to clipboard (no value)"; + this.uxButtonCopyName.Click += this.uxButtonCopyName_Click; + // // uxButtonFavorite // this.uxButtonFavorite.Enabled = false; @@ -1214,6 +1297,14 @@ private void InitializeComponent() private System.Windows.Forms.ToolStripMenuItem uxMenuItemSave; private System.Windows.Forms.ToolStripMenuItem uxButtonExportToTsv; private System.Windows.Forms.ToolStripMenuItem uxMenuItemExportToTsv; + private System.Windows.Forms.ToolStripMenuItem uxButtonCopyAsEnvVar; + private System.Windows.Forms.ToolStripMenuItem uxButtonCopyAsDockerEnv; + private System.Windows.Forms.ToolStripMenuItem uxButtonCopyAsK8sYaml; + private System.Windows.Forms.ToolStripMenuItem uxButtonCopyName; + private System.Windows.Forms.ToolStripMenuItem uxMenuItemCopyAsEnvVar; + private System.Windows.Forms.ToolStripMenuItem uxMenuItemCopyAsDockerEnv; + private System.Windows.Forms.ToolStripMenuItem uxMenuItemCopyAsK8sYaml; + private System.Windows.Forms.ToolStripMenuItem uxMenuItemCopyName; } } diff --git a/Vault/Explorer/MainForm.cs b/Vault/Explorer/MainForm.cs index 620fab6b..c359ace7 100644 --- a/Vault/Explorer/MainForm.cs +++ b/Vault/Explorer/MainForm.cs @@ -11,7 +11,8 @@ namespace Microsoft.Vault.Explorer using System.Linq; using System.Security.Cryptography.X509Certificates; using System.Windows.Forms; - using Microsoft.Azure.KeyVault.Models; + using Azure.Security.KeyVault.Certificates; + using Azure.Security.KeyVault.Secrets; using Microsoft.Vault.Core; using Microsoft.Vault.Explorer.Common; using Microsoft.Vault.Explorer.Config; @@ -40,6 +41,7 @@ public partial class MainForm : Form, ISession private bool _keyDownOccured; private readonly ToolStripButton uxButtonCancel; private readonly Dictionary _tempVaultAliases; // Temporary picked VaultAliases via SubscriptionsManager + private string _titleBase = Globals.AppName; private const string AddNewVaultText = "How to add new vault here..."; private const string PickVaultText = "Pick vault from subscription..."; @@ -115,6 +117,35 @@ protected override void OnShown(EventArgs e) { base.OnShown(e); this.uxPropertyGridSecret.SetLabelColumnWidth(250); + this.RestoreLastVault(); + } + + private void RestoreLastVault() + { + if (this.CurrentVaultAlias != null) + { + return; // Already set via activation URI + } + + string lastAlias = Settings.Default.LastUsedVaultAlias; + if (string.IsNullOrEmpty(lastAlias)) + { + return; + } + + this.uxComboBoxVaultAlias_DropDown(this, EventArgs.Empty); // Populate items + VaultAlias match = this.uxComboBoxVaultAlias.Items.OfType() + .FirstOrDefault(v => string.Equals(v.Alias, lastAlias, StringComparison.OrdinalIgnoreCase)); + if (match == null) + { + return; + } + + this.uxComboBoxVaultAlias.SelectedItem = match; + if (this.SetCurrentVaultAlias()) + { + this.uxMenuItemRefresh.PerformClick(); + } } private void ApplySettings() @@ -132,6 +163,11 @@ private void SaveSettings() UISettings.Default.MainFormSecretsSorting = this.uxListViewSecrets.Sorting; UISettings.Default.MainFormSecretsSortingColumn = this.uxListViewSecrets.SortingColumn; UISettings.Default.Save(); + if (this.CurrentVaultAlias != null) + { + Settings.Default.LastUsedVaultAlias = this.CurrentVaultAlias.Alias; + } + Settings.Default.Save(); } @@ -210,6 +246,16 @@ private bool SaveNewVaults(List newVaults) { foreach (var vault in newVaults) { + if (string.IsNullOrWhiteSpace(vault.UserAlias) || !vault.UserAlias.Contains("@")) + { + MessageBox.Show( + $"Vault '{vault.VaultNames[0]}' was not saved because it is not bound to a specific signed-in account.\n\nUse 'Pick vault from subscription...' and sign in first.", + "Account required", + MessageBoxButtons.OK, + MessageBoxIcon.Warning); + return false; + } + bool success = VaultConfigurationManager.AddVaultConfiguration( vault.VaultNames[0], // vault name vault.Alias, // alias name @@ -277,8 +323,19 @@ private void uxComboBoxVaultAlias_DropDownClosed(object sender, EventArgs e) private void RefreshItemsCount() { - this.uxStatusLabelSecertsCount.Text = string.IsNullOrWhiteSpace(this.uxTextBoxSearch.Text) ? $"{this.uxListViewSecrets.Items.Count} items" : $"{this.uxListViewSecrets.SearchResultsCount} out of {this.uxListViewSecrets.Items.Count} items"; + int expiring = this.uxListViewSecrets.Items.Count > 0 + ? this.uxListViewSecrets.Items.OfType().Count(i => !i.AboutToExpire) + : 0; + + string vaultPrefix = this.CurrentVaultAlias != null ? $"{this.CurrentVaultAlias.Alias} — " : string.Empty; + string itemCount = string.IsNullOrWhiteSpace(this.uxTextBoxSearch.Text) + ? $"{this.uxListViewSecrets.Items.Count} items" + : $"{this.uxListViewSecrets.SearchResultsCount} out of {this.uxListViewSecrets.Items.Count} items"; + string expiringLabel = expiring > 0 ? $" | {expiring} expiring" : string.Empty; + + this.uxStatusLabelSecertsCount.Text = $"{vaultPrefix}{itemCount}{expiringLabel}"; this.uxStatusLabelSecretsSelected.Text = $"{this.uxListViewSecrets.SelectedItems.Count} selected"; + this.Text = expiring > 0 ? $"{this._titleBase} | {expiring} expiring" : this._titleBase; } private bool SetCurrentVaultAlias() @@ -310,6 +367,17 @@ private bool SetCurrentVaultAlias() return false; } + if (smd.CurrentVaultAlias == null) + { + MessageBox.Show( + "No vault was selected.\n\nChoose a subscription, select a vault, then click OK.", + "Vault selection required", + MessageBoxButtons.OK, + MessageBoxIcon.Information); + this.uxComboBoxVaultAlias.SelectedItem = this.CurrentVaultAlias; + return false; + } + // Add vault to temporary collection since it's new this._tempVaultAliases[smd.CurrentVaultAlias.Alias] = smd.CurrentVaultAlias; this.uxComboBoxVaultAlias.Items.Insert(this.uxComboBoxVaultAlias.Items.Count - 2, smd.CurrentVaultAlias); @@ -354,7 +422,7 @@ private void SetCurrentVault() { this.CurrentVault = new Vault(Utils.FullPathToJsonFile(Settings.Default.VaultsJsonFileLocation), VaultAccessTypeEnum.ReadWrite, this.CurrentVaultAlias.VaultNames); // In case that subscription is chosen by the dialog, overwrite permissions taken from vaults.json - if (this.CurrentVaultAlias.UserAlias != null || this.CurrentVault.VaultsConfig.Count == 0) + if (!string.IsNullOrWhiteSpace(this.CurrentVaultAlias.UserAlias) || this.CurrentVault.VaultsConfig.Count == 0) { this.CurrentVault.VaultsConfig[this.CurrentVaultAlias.VaultNames[0]] = new VaultAccessType( new VaultAccess[] { new VaultAccessUserInteractive(this.CurrentVaultAlias.DomainHint, this.CurrentVaultAlias.UserAlias) }, @@ -378,8 +446,8 @@ private async void uxMenuItemRefresh_Click(object sender, EventArgs e) this.uxListViewSecrets.BeginUpdate(); int s = 0, c = 0; Action updateCount = () => this.uxStatusLabelSecertsCount.Text = $"{s + c} secrets"; // We use delegate and Invoke() below to execute on the thread that owns the control - IEnumerable secrets = Enumerable.Empty(); - IEnumerable certificates = Enumerable.Empty(); + IEnumerable secrets = Enumerable.Empty(); + IEnumerable certificates = Enumerable.Empty(); await op.Invoke("access", async () => // List Secrets { @@ -433,7 +501,8 @@ await op.Invoke("access", } else // We were able to list from one or from both collections { - this.Text += $" ({this.CurrentVault.AuthenticatedUserName})"; + this._titleBase = $"{Globals.AppName} ({this.CurrentVault.AuthenticatedUserName})"; + this.Text = this._titleBase; this.uxAddSecret.Visible = this.uxAddSecret2.Visible = this.uxAddCert.Visible = this.uxAddCert2.Visible = this.uxAddFile.Visible = this.uxAddFile2.Visible = this.CurrentVaultAlias.SecretsCollectionEnabled; this.uxAddKVCert.Visible = this.uxAddKVCert2.Visible = this.CurrentVaultAlias.CertificatesCollectionEnabled; this.uxListViewSecrets.AllowDrop = true; @@ -818,6 +887,61 @@ private void uxButtonExportToTsv_Click(object sender, EventArgs e) } } + private async void uxButtonCopyAsEnvVar_Click(object sender, EventArgs e) + { + var item = this.uxListViewSecrets.FirstSelectedItem; + if (null != item) + { + using (var op = this.NewUxOperationWithProgress(this.uxButtonCopyAsEnvVar, this.uxMenuItemCopyAsEnvVar)) + { + PropertyObject po = null; + await op.Invoke($"get {item.Kind} from", async () => po = await item.GetAsync(op.CancellationToken)); + string envName = item.Name.Replace('-', '_').ToUpperInvariant(); + Clipboard.SetText($"{envName}={po.Value}"); + } + } + } + + private async void uxButtonCopyAsDockerEnv_Click(object sender, EventArgs e) + { + var item = this.uxListViewSecrets.FirstSelectedItem; + if (null != item) + { + using (var op = this.NewUxOperationWithProgress(this.uxButtonCopyAsDockerEnv, this.uxMenuItemCopyAsDockerEnv)) + { + PropertyObject po = null; + await op.Invoke($"get {item.Kind} from", async () => po = await item.GetAsync(op.CancellationToken)); + string envName = item.Name.Replace('-', '_').ToUpperInvariant(); + Clipboard.SetText($"--env {envName}={po.Value}"); + } + } + } + + private async void uxButtonCopyAsK8sYaml_Click(object sender, EventArgs e) + { + var item = this.uxListViewSecrets.FirstSelectedItem; + if (null != item) + { + using (var op = this.NewUxOperationWithProgress(this.uxButtonCopyAsK8sYaml, this.uxMenuItemCopyAsK8sYaml)) + { + PropertyObject po = null; + await op.Invoke($"get {item.Kind} from", async () => po = await item.GetAsync(op.CancellationToken)); + string envName = item.Name.Replace('-', '_').ToLowerInvariant(); + string yaml = $"apiVersion: v1\nkind: Secret\nmetadata:\n name: {envName}\nstringData:\n {item.Name}: {po.Value}"; + Clipboard.SetText(yaml); + } + } + } + + private void uxButtonCopyName_Click(object sender, EventArgs e) + { + var item = this.uxListViewSecrets.FirstSelectedItem; + if (null != item) + { + Clipboard.SetText(item.Name); + } + } + private void uxButtonFavorite_Click(object sender, EventArgs e) { if (this.uxListViewSecrets.SelectedItems.Count > 0) @@ -925,4 +1049,4 @@ private void ProcessDropedFiles(string files) #endregion } -} \ No newline at end of file +} diff --git a/Vault/Explorer/Model/Collections/LifetimeActionItem.cs b/Vault/Explorer/Model/Collections/LifetimeActionItem.cs index ec2e3a19..ca157d34 100644 --- a/Vault/Explorer/Model/Collections/LifetimeActionItem.cs +++ b/Vault/Explorer/Model/Collections/LifetimeActionItem.cs @@ -1,14 +1,14 @@ namespace Microsoft.Vault.Explorer.Model.Collections { using System.ComponentModel; - using Microsoft.Azure.KeyVault.Models; + using Azure.Security.KeyVault.Certificates; [DefaultProperty("Type")] [Description("Action and its trigger that will be performed by Key Vault over the lifetime of a certificate.")] public class LifetimeActionItem { [Category("Action")] - public ActionType? Type { get; set; } + public CertificatePolicyAction? Type { get; set; } [Category("Trigger")] public int? DaysBeforeExpiry { get; set; } diff --git a/Vault/Explorer/Model/Collections/LifetimeActionTypeEnumConverter.cs b/Vault/Explorer/Model/Collections/LifetimeActionTypeEnumConverter.cs index 04c57bd0..8293f5a9 100644 --- a/Vault/Explorer/Model/Collections/LifetimeActionTypeEnumConverter.cs +++ b/Vault/Explorer/Model/Collections/LifetimeActionTypeEnumConverter.cs @@ -1,9 +1,9 @@ namespace Microsoft.Vault.Explorer.Model.Collections { - using Microsoft.Azure.KeyVault.Models; + using Azure.Security.KeyVault.Certificates; using Microsoft.Vault.Explorer.Model.ContentTypes; - public class LifetimeActionTypeEnumConverter : CustomEnumTypeConverter + public class LifetimeActionTypeEnumConverter : CustomEnumTypeConverter { } } \ No newline at end of file diff --git a/Vault/Explorer/Model/Files/Secrets/CertificateFileData.cs b/Vault/Explorer/Model/Files/Secrets/CertificateFileData.cs new file mode 100644 index 00000000..cf76d085 --- /dev/null +++ b/Vault/Explorer/Model/Files/Secrets/CertificateFileData.cs @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +namespace Microsoft.Vault.Explorer.Model.Files.Secrets +{ + using System; + using System.Collections.Generic; + using Azure.Security.KeyVault.Certificates; + using Microsoft.Vault.Library; + using Newtonsoft.Json; + + /// + /// DTO used to persist a to a .kv-certificate file. + /// Replaces the old CertificateBundle dependency. + /// + [JsonObject] + public class CertificateFileData + { + [JsonProperty] + public string Id { get; set; } + + [JsonProperty] + public byte[] Cer { get; set; } + + [JsonProperty] + public bool? Enabled { get; set; } + + [JsonProperty] + public DateTimeOffset? ExpiresOn { get; set; } + + [JsonProperty] + public DateTimeOffset? NotBefore { get; set; } + + [JsonProperty] + public Dictionary Tags { get; set; } + + public static CertificateFileData FromCertificate(KeyVaultCertificate c) + { + var data = new CertificateFileData + { + Id = c.Id?.ToString(), + Cer = c.Cer, + Enabled = c.Properties.Enabled, + ExpiresOn = c.Properties.ExpiresOn, + NotBefore = c.Properties.NotBefore, + }; + if (c.Properties.Tags.Count > 0) + { + data.Tags = new Dictionary(c.Properties.Tags); + } + + return data; + } + + public ObjectIdentifier ToObjectIdentifier() + { + if (string.IsNullOrEmpty(this.Id)) + return new ObjectIdentifier(null, null, null, null); + var uri = new Uri(this.Id); + var segments = uri.AbsolutePath.TrimStart('/').Split('/'); + string name = segments.Length > 1 ? segments[1] : null; + string version = segments.Length > 2 ? segments[2] : string.Empty; + string vault = $"{uri.Scheme}://{uri.Host}"; + return new ObjectIdentifier(name, this.Id, version, vault); + } + } +} diff --git a/Vault/Explorer/Model/Files/Secrets/KeyVaultCertificateFile.cs b/Vault/Explorer/Model/Files/Secrets/KeyVaultCertificateFile.cs index 59817c26..351923a0 100644 --- a/Vault/Explorer/Model/Files/Secrets/KeyVaultCertificateFile.cs +++ b/Vault/Explorer/Model/Files/Secrets/KeyVaultCertificateFile.cs @@ -1,20 +1,22 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + namespace Microsoft.Vault.Explorer.Model.Files.Secrets { - using Microsoft.Azure.KeyVault.Models; using Newtonsoft.Json; /// /// Represents .kv-certificate file /// [JsonObject] - public class KeyVaultCertificateFile : KeyVaultFile + public class KeyVaultCertificateFile : KeyVaultFile { public KeyVaultCertificateFile() { } - public KeyVaultCertificateFile(CertificateBundle cb) : base(cb) + public KeyVaultCertificateFile(CertificateFileData data) : base(data) { } } -} \ No newline at end of file +} diff --git a/Vault/Explorer/Model/Files/Secrets/KeyVaultSecretFile.cs b/Vault/Explorer/Model/Files/Secrets/KeyVaultSecretFile.cs index 9eeba5ef..54bff21f 100644 --- a/Vault/Explorer/Model/Files/Secrets/KeyVaultSecretFile.cs +++ b/Vault/Explorer/Model/Files/Secrets/KeyVaultSecretFile.cs @@ -3,21 +3,20 @@ namespace Microsoft.Vault.Explorer.Model.Files.Secrets { - using Microsoft.Azure.KeyVault.Models; using Newtonsoft.Json; /// /// Represents .kv-secret file /// [JsonObject] - public class KeyVaultSecretFile : KeyVaultFile + public class KeyVaultSecretFile : KeyVaultFile { public KeyVaultSecretFile() { } - public KeyVaultSecretFile(SecretBundle secret) : base(secret) + public KeyVaultSecretFile(SecretFileData data) : base(data) { } } -} \ No newline at end of file +} diff --git a/Vault/Explorer/Model/Files/Secrets/SecretFileData.cs b/Vault/Explorer/Model/Files/Secrets/SecretFileData.cs new file mode 100644 index 00000000..b71bba86 --- /dev/null +++ b/Vault/Explorer/Model/Files/Secrets/SecretFileData.cs @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +namespace Microsoft.Vault.Explorer.Model.Files.Secrets +{ + using System; + using System.Collections.Generic; + using Azure.Security.KeyVault.Secrets; + using Microsoft.Vault.Library; + using Newtonsoft.Json; + + /// + /// DTO used to persist a to a .kv-secret file. + /// Replaces the old SecretBundle dependency. + /// + [JsonObject] + public class SecretFileData + { + [JsonProperty] + public string Id { get; set; } + + [JsonProperty] + public string Value { get; set; } + + [JsonProperty] + public string ContentType { get; set; } + + [JsonProperty] + public bool? Enabled { get; set; } + + [JsonProperty] + public DateTimeOffset? ExpiresOn { get; set; } + + [JsonProperty] + public DateTimeOffset? NotBefore { get; set; } + + [JsonProperty] + public Dictionary Tags { get; set; } + + public static SecretFileData FromSecret(KeyVaultSecret s) + { + var data = new SecretFileData + { + Id = s.Id?.ToString(), + Value = s.Value, + ContentType = s.Properties.ContentType, + Enabled = s.Properties.Enabled, + ExpiresOn = s.Properties.ExpiresOn, + NotBefore = s.Properties.NotBefore, + }; + if (s.Properties.Tags.Count > 0) + { + data.Tags = new Dictionary(s.Properties.Tags); + } + + return data; + } + + public static SecretFileData ForNew(string contentType) => new SecretFileData + { + ContentType = contentType, + }; + + public ObjectIdentifier ToObjectIdentifier() + { + if (string.IsNullOrEmpty(this.Id)) + return new ObjectIdentifier(null, null, null, null); + var uri = new Uri(this.Id); + var segments = uri.AbsolutePath.TrimStart('/').Split('/'); + // segments: ["secrets", "name", "version"] or ["secrets", "name"] + string name = segments.Length > 1 ? segments[1] : null; + string version = segments.Length > 2 ? segments[2] : string.Empty; + string vault = $"{uri.Scheme}://{uri.Host}"; + return new ObjectIdentifier(name, this.Id, version, vault); + } + } +} diff --git a/Vault/Explorer/Model/PropObjects/CertificateValueObject.cs b/Vault/Explorer/Model/PropObjects/CertificateValueObject.cs index e5b75ec7..081188b5 100644 --- a/Vault/Explorer/Model/PropObjects/CertificateValueObject.cs +++ b/Vault/Explorer/Model/PropObjects/CertificateValueObject.cs @@ -34,7 +34,9 @@ public CertificateValueObject(string data, string password) this.Data = data; this.Password = password; byte[] rawData = Convert.FromBase64String(data); - this.Certificate = null == password ? new X509Certificate2(rawData) : new X509Certificate2(rawData, password, X509KeyStorageFlags.UserKeySet | X509KeyStorageFlags.Exportable); + this.Certificate = string.IsNullOrEmpty(password) + ? X509CertificateLoader.LoadCertificate(rawData) + : X509CertificateLoader.LoadPkcs12(rawData, password, X509KeyStorageFlags.UserKeySet | X509KeyStorageFlags.Exportable, Pkcs12LoaderLimits.Defaults); } public CertificateValueObject(FileInfo file, string password) : @@ -113,4 +115,4 @@ public static CertificateValueObject FromValue(string value) } } } -} \ No newline at end of file +} diff --git a/Vault/Explorer/Model/PropObjects/PropertyObject.cs b/Vault/Explorer/Model/PropObjects/PropertyObject.cs index 71782ab5..fcb6cb0e 100644 --- a/Vault/Explorer/Model/PropObjects/PropertyObject.cs +++ b/Vault/Explorer/Model/PropObjects/PropertyObject.cs @@ -11,7 +11,6 @@ namespace Microsoft.Vault.Explorer.Model.PropObjects using System.Drawing.Design; using System.IO; using System.Windows.Forms; - using Microsoft.Azure.KeyVault; using Microsoft.Vault.Explorer.Controls; using Microsoft.Vault.Explorer.Controls.MenuItems; using Microsoft.Vault.Explorer.Model.Collections; diff --git a/Vault/Explorer/Model/PropObjects/PropertyObjectCertificate.cs b/Vault/Explorer/Model/PropObjects/PropertyObjectCertificate.cs index 2cff1a6d..39526527 100644 --- a/Vault/Explorer/Model/PropObjects/PropertyObjectCertificate.cs +++ b/Vault/Explorer/Model/PropObjects/PropertyObjectCertificate.cs @@ -11,13 +11,13 @@ namespace Microsoft.Vault.Explorer.Model.PropObjects using System.Linq; using System.Security.Cryptography.X509Certificates; using System.Windows.Forms; - using Microsoft.Azure.KeyVault; - using Microsoft.Azure.KeyVault.Models; + using Azure.Security.KeyVault.Certificates; using Microsoft.Vault.Explorer.Controls.MenuItems; using Microsoft.Vault.Explorer.Dialogs.Passwords; using Microsoft.Vault.Explorer.Model.Collections; using Microsoft.Vault.Explorer.Model.ContentTypes; using Microsoft.Vault.Explorer.Model.Files.Secrets; + using Microsoft.Vault.Library; /// /// Certificate object to edit via PropertyGrid @@ -25,12 +25,14 @@ namespace Microsoft.Vault.Explorer.Model.PropObjects [DefaultProperty("Certificate")] public class PropertyObjectCertificate : PropertyObject { - public readonly CertificateBundle CertificateBundle; + /// The vault-stored certificate resource, or null for a brand-new certificate. + public readonly KeyVaultCertificate CertificateBundle; + public readonly CertificatePolicy CertificatePolicy; [Category("General")] [DisplayName("Certificate")] - [Description("Displays a system dialog that contains the properties of an X.509 certificate and its associated certificate chain. One can also install the cetificate locally by clicking on Install Certificate button in the dialog.")] + [Description("Displays a system dialog that contains the properties of an X.509 certificate and its associated certificate chain.")] [Editor(typeof(CertificateUIEditor), typeof(UITypeEditor))] public X509Certificate2 Certificate { get; } @@ -39,43 +41,61 @@ public class PropertyObjectCertificate : PropertyObject public string Thumbprint => this.Certificate.Thumbprint?.ToLowerInvariant(); [Category("Identifiers")] - [DisplayName("Certificate")] - [TypeConverter(typeof(ExpandableObjectConverter))] - public CertificateIdentifier Id => this.CertificateBundle.CertificateIdentifier; + [DisplayName("Certificate Id")] + public string Id => this.CertificateBundle?.Id?.ToString(); [Category("Identifiers")] - [DisplayName("Key")] - [TypeConverter(typeof(ExpandableObjectConverter))] - public KeyIdentifier KeyId => this.CertificateBundle.KeyIdentifier; + [DisplayName("Key Id")] + public string KeyId => this.CertificateBundle?.KeyId?.ToString(); [Category("Identifiers")] - [DisplayName("Secret")] - [TypeConverter(typeof(ExpandableObjectConverter))] - public SecretIdentifier SecretId => this.CertificateBundle.SecretIdentifier; + [DisplayName("Secret Id")] + public string SecretId => this.CertificateBundle?.SecretId?.ToString(); + + [Category("Policy")] + [DisplayName("Enabled")] + [ReadOnly(true)] + public bool? PolicyEnabled => this.CertificatePolicy?.Enabled; + + [Category("Policy")] + [DisplayName("Issuer")] + [ReadOnly(true)] + public string Issuer => this.CertificatePolicy?.IssuerName; + + [Category("Policy")] + [DisplayName("Certificate Type")] + [ReadOnly(true)] + public string CertificateType => this.CertificatePolicy?.CertificateType; [Category("Policy")] - [DisplayName("Attributes")] + [DisplayName("Key Type")] [ReadOnly(true)] - [TypeConverter(typeof(ExpandableObjectConverter))] - public CertificateAttributes PolicyAttributes => this.CertificatePolicy.Attributes; + public string KeyType => this.CertificatePolicy?.KeyType; [Category("Policy")] - [DisplayName("Certificate properties")] + [DisplayName("Key Size")] [ReadOnly(true)] - [TypeConverter(typeof(ExpandableObjectConverter))] - public X509CertificateProperties X509CertificateProperties => this.CertificatePolicy.X509CertificateProperties; + public int? KeySize => this.CertificatePolicy?.KeySize; [Category("Policy")] - [DisplayName("Key properties")] + [DisplayName("Exportable")] [ReadOnly(true)] - [TypeConverter(typeof(ExpandableObjectConverter))] - public KeyProperties KeyProperties => this.CertificatePolicy.KeyProperties; + public bool? Exportable => this.CertificatePolicy?.Exportable; [Category("Policy")] - [DisplayName("Secret properties")] + [DisplayName("Reuse Key")] [ReadOnly(true)] - [TypeConverter(typeof(ExpandableObjectConverter))] - public SecretProperties SecretProperties => this.CertificatePolicy.SecretProperties; + public bool? ReuseKey => this.CertificatePolicy?.ReuseKey; + + [Category("Policy")] + [DisplayName("Content Type")] + [ReadOnly(true)] + public string PolicyContentType => this.CertificatePolicy?.ContentType?.ToString(); + + [Category("Policy")] + [DisplayName("Subject")] + [ReadOnly(true)] + public string Subject => this.CertificatePolicy?.Subject; private ObservableLifetimeActionsCollection _lifetimeActions; @@ -89,21 +109,35 @@ public ObservableLifetimeActionsCollection LifetimeActions set { this._lifetimeActions = value; - if (null != this.CertificatePolicy) + if (this.CertificatePolicy?.LifetimeActions != null) { - this.CertificatePolicy.LifetimeActions = this.LifetimeActionsToList(); + this.CertificatePolicy.LifetimeActions.Clear(); + foreach (var lai in value) + { + this.CertificatePolicy.LifetimeActions.Add(new LifetimeAction(lai.Type ?? CertificatePolicyAction.AutoRenew) + { + DaysBeforeExpiry = lai.DaysBeforeExpiry, + LifetimePercentage = lai.LifetimePercentage, + }); + } } } } - [Category("Policy")] - [DisplayName("Issuer parameters")] - [ReadOnly(true)] - [TypeConverter(typeof(ExpandableObjectConverter))] - public IssuerParameters IssuerReference => this.CertificatePolicy.IssuerParameters; - - public PropertyObjectCertificate(CertificateBundle certificateBundle, CertificatePolicy policy, X509Certificate2 certificate, PropertyChangedEventHandler propertyChanged) : - base(certificateBundle.CertificateIdentifier, certificateBundle.Tags, certificateBundle.Attributes.Enabled, certificateBundle.Attributes.Expires, certificateBundle.Attributes.NotBefore, propertyChanged) + public PropertyObjectCertificate(KeyVaultCertificate certificateBundle, CertificatePolicy policy, X509Certificate2 certificate, PropertyChangedEventHandler propertyChanged) : + base( + certificateBundle != null + ? new ObjectIdentifier( + certificateBundle.Name, + certificateBundle.Id?.ToString() ?? string.Empty, + certificateBundle.Properties.Version ?? string.Empty, + certificateBundle.Properties.VaultUri?.ToString() ?? string.Empty) + : new ObjectIdentifier(null, null, null, null), + certificateBundle?.Properties.Tags, + certificateBundle?.Properties.Enabled, + certificateBundle?.Properties.ExpiresOn?.UtcDateTime, + certificateBundle?.Properties.NotBefore?.UtcDateTime, + propertyChanged) { this.CertificateBundle = certificateBundle; this.CertificatePolicy = policy; @@ -111,11 +145,16 @@ public PropertyObjectCertificate(CertificateBundle certificateBundle, Certificat this._contentType = ContentType.Pkcs12; this._value = certificate.Thumbprint.ToLowerInvariant(); var olac = new ObservableLifetimeActionsCollection(); - if (null != this.CertificatePolicy?.LifetimeActions) + if (this.CertificatePolicy?.LifetimeActions != null) { foreach (var la in this.CertificatePolicy.LifetimeActions) { - olac.Add(new LifetimeActionItem { Type = la.Action.ActionType, DaysBeforeExpiry = la.Trigger.DaysBeforeExpiry, LifetimePercentage = la.Trigger.LifetimePercentage }); + olac.Add(new LifetimeActionItem + { + Type = la.Action, + DaysBeforeExpiry = la.DaysBeforeExpiry, + LifetimePercentage = la.LifetimePercentage, + }); } } @@ -123,8 +162,6 @@ public PropertyObjectCertificate(CertificateBundle certificateBundle, Certificat this.LifetimeActions.SetPropertyChangedEventHandler(propertyChanged); } - public CertificateAttributes ToCertificateAttributes() => new CertificateAttributes { Enabled = this.Enabled, Expires = this.Expires, NotBefore = this.NotBefore }; - public override string GetKeyVaultFileExtension() => ContentType.KeyVaultCertificate.ToExtension(); public override DataObject GetClipboardValue() @@ -141,8 +178,11 @@ public override void SaveToFile(string fullName) { case ContentType.KeyVaultSecret: throw new InvalidOperationException("One can't save key vault certificate as key vault secret"); - case ContentType.KeyVaultCertificate: // Serialize the entire secret as encrypted JSON for current user - File.WriteAllText(fullName, new KeyVaultCertificateFile(this.CertificateBundle).Serialize()); + case ContentType.KeyVaultCertificate: + if (this.CertificateBundle != null) + { + File.WriteAllText(fullName, new KeyVaultCertificateFile(CertificateFileData.FromCertificate(this.CertificateBundle)).Serialize()); + } break; case ContentType.KeyVaultLink: File.WriteAllText(fullName, this.GetLinkAsInternetShortcut()); @@ -180,9 +220,6 @@ public override void PopulateExpiration() { } - public override string AreCustomTagsValid() => ""; // Return always valid - - private IList LifetimeActionsToList() => - (from lai in this.LifetimeActions select new LifetimeAction(new Trigger(lai.LifetimePercentage, lai.DaysBeforeExpiry), new Azure.KeyVault.Models.Action(lai.Type))).ToList(); + public override string AreCustomTagsValid() => ""; } -} \ No newline at end of file +} diff --git a/Vault/Explorer/Model/PropObjects/PropertyObjectSecret.cs b/Vault/Explorer/Model/PropObjects/PropertyObjectSecret.cs index 7b8817ba..534c89ed 100644 --- a/Vault/Explorer/Model/PropObjects/PropertyObjectSecret.cs +++ b/Vault/Explorer/Model/PropObjects/PropertyObjectSecret.cs @@ -13,7 +13,7 @@ namespace Microsoft.Vault.Explorer.Model.PropObjects using System.Text; using System.Text.RegularExpressions; using System.Windows.Forms; - using Microsoft.Azure.KeyVault.Models; + using Azure.Security.KeyVault.Secrets; using Microsoft.Vault.Explorer.Controls.MenuItems; using Microsoft.Vault.Explorer.Model.Collections; using Microsoft.Vault.Explorer.Model.ContentTypes; @@ -28,9 +28,9 @@ namespace Microsoft.Vault.Explorer.Model.PropObjects public class PropertyObjectSecret : PropertyObject { /// - /// Original secret + /// Original secret (null when creating a brand-new secret) /// - private readonly SecretBundle _secret; + private readonly KeyVaultSecret _secret; private readonly CustomTags _customTags; @@ -55,16 +55,55 @@ public string Version get { return this._version; } } - public PropertyObjectSecret(SecretBundle secret, PropertyChangedEventHandler propertyChanged) : - base(secret.SecretIdentifier, secret.Tags, secret.Attributes.Enabled, secret.Attributes.Expires, secret.Attributes.NotBefore, propertyChanged) + /// Constructor for an existing secret fetched from vault. + public PropertyObjectSecret(KeyVaultSecret secret, PropertyChangedEventHandler propertyChanged) : + base( + new ObjectIdentifier( + secret.Name, + secret.Id?.ToString() ?? string.Empty, + secret.Properties.Version ?? string.Empty, + secret.Properties.VaultUri?.ToString() ?? string.Empty), + secret.Properties.Tags, + secret.Properties.Enabled, + secret.Properties.ExpiresOn?.UtcDateTime, + secret.Properties.NotBefore?.UtcDateTime, + propertyChanged) { this._secret = secret; - this._version = secret.SecretIdentifier?.Version; - this._contentType = ContentTypeEnumConverter.GetValue(secret.ContentType); + this._version = secret.Properties.Version; + this._contentType = ContentTypeEnumConverter.GetValue(secret.Properties.ContentType); this._value = this._contentType.FromRawValue(secret.Value); this._customTags = Utils.LoadFromJsonFile(Settings.Default.CustomTagsJsonFileLocation, isOptional: true); } + /// Constructor for a secret loaded from a .kv-secret file. + public PropertyObjectSecret(SecretFileData fileData, PropertyChangedEventHandler propertyChanged) : + base( + fileData.ToObjectIdentifier(), + fileData.Tags, + fileData.Enabled, + fileData.ExpiresOn?.UtcDateTime, + fileData.NotBefore?.UtcDateTime, + propertyChanged) + { + this._secret = null; + this._version = fileData.ToObjectIdentifier().Version; + this._contentType = ContentTypeEnumConverter.GetValue(fileData.ContentType); + this._value = this._contentType.FromRawValue(fileData.Value); + this._customTags = Utils.LoadFromJsonFile(Settings.Default.CustomTagsJsonFileLocation, isOptional: true); + } + + /// Constructor for a brand-new secret (not yet in vault). + public PropertyObjectSecret(string contentType, PropertyChangedEventHandler propertyChanged) : + base(new ObjectIdentifier(null, null, null, null), null, null, null, null, propertyChanged) + { + this._secret = null; + this._version = null; + this._contentType = ContentTypeEnumConverter.GetValue(contentType); + this._value = null; + this._customTags = Utils.LoadFromJsonFile(Settings.Default.CustomTagsJsonFileLocation, isOptional: true); + } + protected override IEnumerable GetValueBasedCustomTags() { // Add tags based on all named groups in the value regex @@ -182,13 +221,6 @@ public override void PopulateExpiration() this.Expires = default(TimeSpan) == this.SecretKind.DefaultExpiration ? null : DateTime.UtcNow.Add(this.SecretKind.DefaultExpiration); } - public SecretAttributes ToSecretAttributes() => new SecretAttributes - { - Enabled = this.Enabled, - Expires = this.Expires, - NotBefore = this.NotBefore, - }; - public override string GetKeyVaultFileExtension() => ContentType.KeyVaultSecret.ToExtension(); public override DataObject GetClipboardValue() @@ -204,8 +236,11 @@ public override void SaveToFile(string fullName) Directory.CreateDirectory(Path.GetDirectoryName(fullName)); switch (ContentTypeUtils.FromExtension(Path.GetExtension(fullName))) { - case ContentType.KeyVaultSecret: // Serialize the entire secret as encrypted JSON for current user - File.WriteAllText(fullName, new KeyVaultSecretFile(this._secret).Serialize()); + case ContentType.KeyVaultSecret: + if (this._secret != null) + { + File.WriteAllText(fullName, new KeyVaultSecretFile(SecretFileData.FromSecret(this._secret)).Serialize()); + } break; case ContentType.KeyVaultCertificate: throw new InvalidOperationException("One can't save key vault secret as key vault certificate"); @@ -224,4 +259,4 @@ public override void SaveToFile(string fullName) } } } -} \ No newline at end of file +} diff --git a/Vault/Explorer/Properties/PublishProfiles/ClickOnceProfile.pubxml b/Vault/Explorer/Properties/PublishProfiles/ClickOnceProfile.pubxml index e538b27f..2b114429 100644 --- a/Vault/Explorer/Properties/PublishProfiles/ClickOnceProfile.pubxml +++ b/Vault/Explorer/Properties/PublishProfiles/ClickOnceProfile.pubxml @@ -14,7 +14,7 @@ True true False - bin\Release\net8.0-windows10.0.17763.0\win-x64\app.publish\ + bin\Release\net10.0-windows10.0.17763.0\win-x64\app.publish\ bin\publish\ ClickOnce False @@ -34,6 +34,6 @@ 1.0.0.0 Any CPU win-x64 - net8.0-windows10.0.17763.0 + net10.0-windows10.0.17763.0 - \ No newline at end of file + diff --git a/Vault/Explorer/Settings.cs b/Vault/Explorer/Settings.cs index f0fc3b7e..eed1fc77 100644 --- a/Vault/Explorer/Settings.cs +++ b/Vault/Explorer/Settings.cs @@ -163,6 +163,15 @@ public string UserAccountNames set { this[nameof(this.UserAccountNames)] = value; } } + [UserScopedSetting] + [DefaultSettingValue("")] + [Browsable(false)] + public string LastUsedVaultAlias + { + get { return (string)this[nameof(this.LastUsedVaultAlias)]; } + set { this[nameof(this.LastUsedVaultAlias)] = value; } + } + [UserScopedSetting] [DefaultSettingValue("True")] [Browsable(false)] diff --git a/Vault/Explorer/VaultExplorer.csproj b/Vault/Explorer/VaultExplorer.csproj index ac465d87..925e07bf 100644 --- a/Vault/Explorer/VaultExplorer.csproj +++ b/Vault/Explorer/VaultExplorer.csproj @@ -2,7 +2,7 @@ - net8.0-windows10.0.17763.0 + net10.0-windows10.0.17763.0 win-x64 WinExe Microsoft.Vault.Explorer @@ -17,7 +17,8 @@ true true ;NU1507 - true + $(MSBuildWarningsAsMessages);MSB3277 + false LocalIntranet Properties\app.manifest True @@ -28,10 +29,10 @@ - + + - diff --git a/Vault/Library/KeyVaultClientEx.cs b/Vault/Library/KeyVaultClientEx.cs index c946c931..056d7e8b 100644 --- a/Vault/Library/KeyVaultClientEx.cs +++ b/Vault/Library/KeyVaultClientEx.cs @@ -3,29 +3,41 @@ namespace Microsoft.Vault.Library { - using Microsoft.Azure.KeyVault; + using System; + using Azure.Core; + using Azure.Security.KeyVault.Certificates; + using Azure.Security.KeyVault.Secrets; /// - /// Simple wrapper around KeyVaultClient + /// Holds and for a single vault, + /// replacing the old KeyVaultClientEx which extended the deprecated KeyVaultClient. /// - internal class KeyVaultClientEx : KeyVaultClient + internal sealed class VaultKeyValueClient { public readonly string VaultName; public readonly string VaultUri; + public readonly SecretClient SecretClient; + public readonly CertificateClient CertificateClient; - public KeyVaultClientEx(string vaultName, AuthenticationCallback authenticationCallback) : base(authenticationCallback) + internal VaultKeyValueClient(string vaultName, TokenCredential credential) { Utils.GuardVaultName(vaultName); this.VaultName = vaultName; - this.VaultUri = string.Format(Consts.AzureVaultUriFormat, this.VaultName); + this.VaultUri = string.Format(Consts.AzureVaultUriFormat, vaultName); + var vaultUri = new Uri(this.VaultUri); + this.SecretClient = new SecretClient(vaultUri, credential); + this.CertificateClient = new CertificateClient(vaultUri, credential); } - private string ToIdentifier(string endpoint, string name, string version) => $"{this.VaultUri}/{endpoint}/{name}" + (string.IsNullOrEmpty(version) ? "" : $"/{version}"); + private string ToIdentifier(string endpoint, string name, string version) => + $"{this.VaultUri}/{endpoint}/{name}" + (string.IsNullOrEmpty(version) ? "" : $"/{version}"); - public string ToSecretIdentifier(string secretName, string version = null) => this.ToIdentifier(Consts.SecretsEndpoint, secretName, version); + public string ToSecretIdentifier(string secretName, string version = null) => + this.ToIdentifier(Consts.SecretsEndpoint, secretName, version); - public string ToCertificateIdentifier(string certificateName, string version = null) => this.ToIdentifier(Consts.CertificatesEndpoint, certificateName, version); + public string ToCertificateIdentifier(string certificateName, string version = null) => + this.ToIdentifier(Consts.CertificatesEndpoint, certificateName, version); public override string ToString() => this.VaultUri; } -} \ No newline at end of file +} diff --git a/Vault/Library/ObjectIdentifier.cs b/Vault/Library/ObjectIdentifier.cs new file mode 100644 index 00000000..094bbb53 --- /dev/null +++ b/Vault/Library/ObjectIdentifier.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +namespace Microsoft.Vault.Library +{ + /// + /// Local replacement for the deprecated Microsoft.Azure.KeyVault.ObjectIdentifier. + /// Provides the same Name/Identifier/Version/Vault surface used throughout the UI layer. + /// + public sealed class ObjectIdentifier + { + /// The object name (e.g. "my-secret"). + public string Name { get; } + + /// The full versioned URI (e.g. "https://vault.azure.net/secrets/name/version"). + public string Identifier { get; } + + /// The version string, or empty string for the latest version. + public string Version { get; } + + /// The base vault URI (e.g. "https://vault.azure.net"). + public string Vault { get; } + + public ObjectIdentifier(string name, string identifier, string version, string vault) + { + this.Name = name ?? string.Empty; + this.Identifier = identifier ?? string.Empty; + this.Version = version ?? string.Empty; + this.Vault = vault ?? string.Empty; + } + } +} diff --git a/Vault/Library/Vault.cs b/Vault/Library/Vault.cs index 3ae16907..453928d9 100644 --- a/Vault/Library/Vault.cs +++ b/Vault/Library/Vault.cs @@ -11,8 +11,9 @@ namespace Microsoft.Vault.Library using System.Security.Cryptography.X509Certificates; using System.Threading; using System.Threading.Tasks; - using Microsoft.Azure.KeyVault; - using Microsoft.Azure.KeyVault.Models; + using Azure; + using Azure.Security.KeyVault.Certificates; + using Azure.Security.KeyVault.Secrets; using Microsoft.Vault.Core; using Newtonsoft.Json; @@ -30,21 +31,20 @@ namespace Microsoft.Vault.Library /// public class Vault { - private readonly KeyVaultClientEx[] _keyVaultClients; + private readonly VaultKeyValueClient[] _keyVaultClients; private bool Secondary => this._keyVaultClients.Length == 2; public readonly string VaultsConfigFile; public readonly string[] VaultNames; public readonly VaultsConfig VaultsConfig; - private static readonly Task CompletedTask = Task.FromResult(0); // Dummy completed task to be used for secondary operations, in case we work with only Primary vault + private static readonly Task CompletedTask = Task.FromResult(0); private static readonly object Lock = new object(); /// /// UserPrincipalName, in UPN format of the currently authenticated user, in case of cert based access the value will /// be: {Environment.UserDomainName}\{Environment.UserName} - /// The value will be set only after successful opertaion to vault, like: - /// + /// The value will be set only after successful operation to vault. /// public string AuthenticatedUserName { get; private set; } @@ -59,9 +59,6 @@ public class Vault /// /// Creates the vault management instance based on provided Vaults Config dictionary /// - /// Vaults Config dictionary - /// ReadOnly or ReadWrite - /// Single or Dual public Vault(VaultsConfig vaultsConfig, VaultAccessTypeEnum accessType, params string[] vaultNames) { Guard.ArgumentNotNull(vaultsConfig, nameof(vaultsConfig)); @@ -71,9 +68,9 @@ public Vault(VaultsConfig vaultsConfig, VaultAccessTypeEnum accessType, params s switch (this.VaultNames.Length) { case 1: - this._keyVaultClients = new [] + this._keyVaultClients = new[] { - this.CreateKeyVaultClientEx(accessType, this.VaultNames[0]), + this.CreateVaultKeyValueClient(accessType, this.VaultNames[0]), }; break; case 2: @@ -84,9 +81,10 @@ public Vault(VaultsConfig vaultsConfig, VaultAccessTypeEnum accessType, params s throw new ArgumentException($"Primary vault name {primaryVaultName} is equal to secondary vault name {secondaryVaultName}"); } - this._keyVaultClients = new KeyVaultClientEx[2] + this._keyVaultClients = new VaultKeyValueClient[2] { - this.CreateKeyVaultClientEx(accessType, primaryVaultName), this.CreateKeyVaultClientEx(accessType, secondaryVaultName), + this.CreateVaultKeyValueClient(accessType, primaryVaultName), + this.CreateVaultKeyValueClient(accessType, secondaryVaultName), }; break; default: @@ -97,14 +95,6 @@ public Vault(VaultsConfig vaultsConfig, VaultAccessTypeEnum accessType, params s /// /// Load specified Vaults.json configuration file and creates the vault management instance /// - /// - /// Optional path to Vaults.json file, if NULL or empty default Vaults.json will be used, in such case Vaults.json - /// location will be resolved in the following order: - /// 1. Side-by-side with the current process location - /// 2. Side-by-side with the current (executing) assembly - /// - /// ReadOnly or ReadWrite - /// Single or Dual public Vault(string vaultsConfigFile, VaultAccessTypeEnum accessType, params string[] vaultNames) : this(DeserializeVaultsConfigFromFile(ref vaultsConfigFile), accessType, vaultNames) { @@ -114,8 +104,6 @@ public Vault(string vaultsConfigFile, VaultAccessTypeEnum accessType, params str /// /// Single (primary) vault management constructor /// - /// ReadOnly or ReadWrite - /// Single or pair public Vault(VaultAccessTypeEnum accessType, params string[] vaultNames) : this(string.Empty, accessType, vaultNames) { @@ -124,8 +112,6 @@ public Vault(VaultAccessTypeEnum accessType, params string[] vaultNames) /// /// Single (primary) or Dual (primary and secondary) vault management constructor /// - /// ReadOnly or ReadWrite - /// Vault name public Vault(VaultAccessTypeEnum accessType, string vaultName) : this(accessType, new[] { vaultName }) { @@ -134,9 +120,6 @@ public Vault(VaultAccessTypeEnum accessType, string vaultName) /// /// Dual (primary and secondary) vault management constructor /// - /// ReadOnly or ReadWrite - /// Primary vault name - /// Secodnary vault name public Vault(VaultAccessTypeEnum accessType, string primaryVaultName, string secondaryVaultName) : this(accessType, new[] { primaryVaultName, secondaryVaultName }) { @@ -148,11 +131,8 @@ private static VaultsConfig DeserializeVaultsConfigFromFile(ref string vaultsCon { TypeNameHandling = TypeNameHandling.Auto, }; - if (string.IsNullOrWhiteSpace(vaultsConfigFile)) // Config file was not provied, use the default one + if (string.IsNullOrWhiteSpace(vaultsConfigFile)) { - // Vaults.json location will be resolved in the following order: - // 1. Side-by-side with the current process location - // 2. Side-by-side with the current (executing) assembly vaultsConfigFile = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, Consts.VaultsJsonConfig); if (!File.Exists(vaultsConfigFile)) { @@ -163,87 +143,54 @@ private static VaultsConfig DeserializeVaultsConfigFromFile(ref string vaultsCon return JsonConvert.DeserializeObject(File.ReadAllText(vaultsConfigFile), settings); } - private KeyVaultClientEx CreateKeyVaultClientEx(VaultAccessTypeEnum accessType, string vaultName) => - new KeyVaultClientEx(vaultName, async (authority, resource, scope) => - { - // Prepare data outside the lock - VaultAccess[] vas; - string userAliasType; + private VaultKeyValueClient CreateVaultKeyValueClient(VaultAccessTypeEnum accessType, string vaultName) + { + VaultAccess[] vas; + string userAliasType; - lock (Lock) + lock (Lock) + { + Utils.GuardVaultName(vaultName); + if (false == this.VaultsConfig.ContainsKey(vaultName)) { - Utils.GuardVaultName(vaultName); - if (false == this.VaultsConfig.ContainsKey(vaultName)) - { - throw new KeyNotFoundException($"{vaultName} is not found in {this.VaultsConfigFile}"); - } - - VaultAccessType vat = this.VaultsConfig[vaultName]; - vas = accessType == VaultAccessTypeEnum.ReadOnly ? vat.ReadOnly : vat.ReadWrite; - - // Order possible VaultAccess options by Order property - vas = vas.OrderBy(va => va.Order).ToArray(); - - // Get user alias for interactive authentication - userAliasType = (from va in vas where va is VaultAccessUserInteractive select (VaultAccessUserInteractive)va).FirstOrDefault()?.UserAliasType; + throw new KeyNotFoundException($"{vaultName} is not found in {this.VaultsConfigFile}"); } - // Convert resource URL to MSAL scopes - string[] scopes = VaultAccess.ConvertResourceToScopes(resource); - - Queue exceptions = new Queue(); - string vaultAccessTypes = ""; - foreach (VaultAccess va in vas) - { - try - { - // If user alias type is different from environment, force login prompt, otherwise silently login - var authResult = await va.AcquireTokenAsync(scopes, userAliasType); - - if (authResult.Account == null) - { - // should never happen - throw new VaultAccessException("The authentication result doesn't include account information"); - } - - this.AuthenticatedUserName = authResult.Account.Username ?? userAliasType; - - return authResult.AccessToken; - } - catch (Exception e) - { - vaultAccessTypes += $" {va}"; - exceptions.Enqueue(e); - } - } + VaultAccessType vat = this.VaultsConfig[vaultName]; + vas = accessType == VaultAccessTypeEnum.ReadOnly ? vat.ReadOnly : vat.ReadWrite; + vas = vas.OrderBy(va => va.Order).ToArray(); + userAliasType = (from va in vas where va is VaultAccessUserInteractive select (VaultAccessUserInteractive)va).FirstOrDefault()?.UserAliasType; + } - throw new VaultAccessException($"Failed to get access to {vaultName} with all possible vault access type(s){vaultAccessTypes}", exceptions.ToArray()); + var credential = new VaultAccessTokenCredential(vas, userAliasType, vaultName, username => + { + this.AuthenticatedUserName = username; }); + return new VaultKeyValueClient(vaultName, credential); + } + #endregion #region Secrets /// - /// Gets specified secret by name from vault - /// This function will prefer vault in the same region, in case we failed (including secret not found) it will fallback - /// to other region - /// In case we failed in both regions it will throw aggregated SecretException + /// Gets specified secret by name from vault. + /// Prefers vault in same region; falls back to other region on failure. /// - /// The name the secret in the given vault - /// The version of the secret (optional) - /// Optional cancellation token - /// SecretBundle - public async Task GetSecretAsync(string secretName, string secretVersion = null, CancellationToken cancellationToken = default(CancellationToken)) + public async Task GetSecretAsync(string secretName, string secretVersion = null, CancellationToken cancellationToken = default) { Queue exceptions = new Queue(); string vaults = ""; - secretVersion = secretVersion ?? string.Empty; foreach (var kv in this._keyVaultClients) { try { - return await kv.GetSecretAsync(kv.VaultUri, secretName, secretVersion, cancellationToken).ConfigureAwait(false); + var response = await kv.SecretClient.GetSecretAsync( + secretName, + string.IsNullOrEmpty(secretVersion) ? null : secretVersion, + cancellationToken).ConfigureAwait(false); + return response.Value; } catch (Exception e) { @@ -258,21 +205,21 @@ private KeyVaultClientEx CreateKeyVaultClientEx(VaultAccessTypeEnum accessType, /// /// Sets a secret in both vaults /// - /// The name the secret in the given vault - /// The value of the secret - /// Application-specific metadata in the form of key-value pairs - /// Type of the secret value such as a password - /// - /// Attributes for the secret. For more information on possible attributes, - /// - /// - /// Optional cancellation token - /// A response message containing the updated secret from first vault - public async Task SetSecretAsync(string secretName, string value, Dictionary tags = null, string contentType = null, SecretAttributes secretAttributes = null, CancellationToken cancellationToken = default(CancellationToken)) + public async Task SetSecretAsync(string secretName, string value, Dictionary tags = null, string contentType = null, bool? enabled = null, DateTimeOffset? expires = null, DateTimeOffset? notBefore = null, CancellationToken cancellationToken = default) { tags = Utils.AddMd5ChangedBy(tags, value, this.AuthenticatedUserName); - var t0 = this._keyVaultClients[0].SetSecretAsync(this._keyVaultClients[0].VaultUri, secretName, value, tags, contentType, secretAttributes, cancellationToken); - var t1 = this.Secondary ? this._keyVaultClients[1].SetSecretAsync(this._keyVaultClients[1].VaultUri, secretName, value, tags, contentType, secretAttributes, cancellationToken) : CompletedTask; + var secret = new KeyVaultSecret(secretName, value); + secret.Properties.ContentType = contentType; + secret.Properties.Enabled = enabled; + secret.Properties.ExpiresOn = expires; + secret.Properties.NotBefore = notBefore; + if (tags != null) + { + foreach (var kvp in tags) secret.Properties.Tags[kvp.Key] = kvp.Value; + } + + var t0 = this._keyVaultClients[0].SecretClient.SetSecretAsync(secret, cancellationToken); + var t1 = this.Secondary ? this._keyVaultClients[1].SecretClient.SetSecretAsync(secret, cancellationToken) : Task.FromResult>(null); await Task.WhenAll(t0, t1).ContinueWith(t => { if (t0.IsFaulted && t1.IsFaulted) @@ -290,28 +237,32 @@ await Task.WhenAll(t0, t1).ContinueWith(t => throw new SecretException($"Failed to set secret {secretName} in vault {this._keyVaultClients[1]}", t1.Exception); } }); - return t0.Result; + return t0.Result.Value; } /// - /// Updates the attributes associated with the specified secret in both vaults + /// Updates the attributes associated with the specified secret in both vaults. + /// Fetches the current version first so UpdateSecretPropertiesAsync gets a versioned URI. /// - /// The name of the secret in the given vault - /// The secret version (optional) - /// Application-specific metadata in the form of key-value pairs - /// Type of the secret value such as a password - /// - /// Attributes for the secret. For more information on possible attributes, - /// - /// - /// Optional cancellation token - /// A response message containing the updated secret from first vault - public async Task UpdateSecretAsync(string secretName, string secretVersion = null, Dictionary tags = null, string contentType = null, SecretAttributes secretAttributes = null, CancellationToken cancellationToken = default(CancellationToken)) + public async Task UpdateSecretAsync(string secretName, string secretVersion = null, Dictionary tags = null, string contentType = null, bool? enabled = null, DateTimeOffset? expires = null, DateTimeOffset? notBefore = null, CancellationToken cancellationToken = default) { tags = Utils.AddMd5ChangedBy(tags, null, this.AuthenticatedUserName); - secretVersion = secretVersion ?? string.Empty; - var t0 = this._keyVaultClients[0].UpdateSecretAsync(this._keyVaultClients[0].VaultUri, secretName, secretVersion, contentType, secretAttributes, tags, cancellationToken); - var t1 = this.Secondary ? this._keyVaultClients[1].UpdateSecretAsync(this._keyVaultClients[1].VaultUri, secretName, secretVersion, contentType, secretAttributes, tags, cancellationToken) : CompletedTask; + string version = string.IsNullOrEmpty(secretVersion) ? null : secretVersion; + + var current0 = await this._keyVaultClients[0].SecretClient.GetSecretAsync(secretName, version, cancellationToken).ConfigureAwait(false); + var props0 = current0.Value.Properties; + ApplyToSecretProperties(props0, tags, contentType, enabled, expires, notBefore); + var t0 = this._keyVaultClients[0].SecretClient.UpdateSecretPropertiesAsync(props0, cancellationToken); + + Task> t1 = Task.FromResult>(null); + if (this.Secondary) + { + var current1 = await this._keyVaultClients[1].SecretClient.GetSecretAsync(secretName, version, cancellationToken).ConfigureAwait(false); + var props1 = current1.Value.Properties; + ApplyToSecretProperties(props1, tags, contentType, enabled, expires, notBefore); + t1 = this._keyVaultClients[1].SecretClient.UpdateSecretPropertiesAsync(props1, cancellationToken); + } + await Task.WhenAll(t0, t1).ContinueWith(t => { if (t0.IsFaulted && t1.IsFaulted) @@ -329,83 +280,52 @@ await Task.WhenAll(t0, t1).ContinueWith(t => throw new SecretException($"Failed to update secret {secretName} in vault {this._keyVaultClients[1]}", t1.Exception); } }); - return t0.Result; + return t0.Result.Value; + } + + private static void ApplyToSecretProperties(SecretProperties props, Dictionary tags, string contentType, bool? enabled, DateTimeOffset? expires, DateTimeOffset? notBefore) + { + props.ContentType = contentType; + props.Enabled = enabled; + props.ExpiresOn = expires; + props.NotBefore = notBefore; + props.Tags.Clear(); + if (tags != null) + { + foreach (var kvp in tags) props.Tags[kvp.Key] = kvp.Value; + } } /// /// List all secrets from specified vault - /// This function will only look in single specified Azure Key Vault. It will not fallback to other region. /// - /// 0 - current region, 1 - other region - /// Optional progress update delegate - /// Optional cancellation token - /// IEnumerable of SecretItem - public async Task> ListSecretsAsync(int regionIndex = 0, ListOperationProgressUpdate listSecretsProgressUpdate = null, CancellationToken cancellationToken = default(CancellationToken)) + public async Task> ListSecretsAsync(int regionIndex = 0, ListOperationProgressUpdate listSecretsProgressUpdate = null, CancellationToken cancellationToken = default) { Guard.ArgumentIsValidRegion(regionIndex, nameof(regionIndex)); Guard.ArgumentInRange(regionIndex, 0, this._keyVaultClients.Length - 1, nameof(regionIndex)); - var listResponse = await this._keyVaultClients[regionIndex].GetSecretsAsync(this._keyVaultClients[regionIndex].VaultUri, Consts.ListSecretsMaxResults, cancellationToken: cancellationToken).ConfigureAwait(false); - Dictionary result = new Dictionary(StringComparer.InvariantCulture); - if (listResponse == null) // No secrets in the vault - { - return result.Values; - } - - foreach (SecretItem si in listResponse) - { - result[si.Identifier.Name] = si; - } - - listSecretsProgressUpdate?.Invoke(result.Count); - - while (!string.IsNullOrEmpty(listResponse.NextPageLink)) + var result = new Dictionary(StringComparer.InvariantCulture); + await foreach (var sp in this._keyVaultClients[regionIndex].SecretClient.GetPropertiesOfSecretsAsync(cancellationToken).ConfigureAwait(false)) { - listResponse = await this._keyVaultClients[regionIndex].GetSecretsNextAsync(listResponse.NextPageLink, cancellationToken).ConfigureAwait(false); - foreach (SecretItem si in listResponse) - { - result[si.Identifier.Name] = si; - } - + result[sp.Name] = sp; listSecretsProgressUpdate?.Invoke(result.Count); } return result.Values; } - /// /// List all the versions of a specified secret - /// This function will only look in single specified Azure Key Vault. It will not fallback to other region. /// - /// The name of the secret in the given vault - /// 0 - current region, 1 - other region - /// Optional cancellation token - /// - public async Task> GetSecretVersionsAsync(string secretName, int regionIndex = 0, CancellationToken cancellationToken = default(CancellationToken)) + public async Task> GetSecretVersionsAsync(string secretName, int regionIndex = 0, CancellationToken cancellationToken = default) { Guard.ArgumentNotNullOrWhitespace(secretName, nameof(secretName)); Guard.ArgumentIsValidRegion(regionIndex, nameof(regionIndex)); Guard.ArgumentInRange(regionIndex, 0, this._keyVaultClients.Length - 1, nameof(regionIndex)); - var listResponse = await this._keyVaultClients[regionIndex].GetSecretVersionsAsync(this._keyVaultClients[regionIndex].VaultUri, secretName, Consts.GetSecretVersionsMaxResults, cancellationToken: cancellationToken).ConfigureAwait(false); - Dictionary result = new Dictionary(StringComparer.InvariantCulture); - if (listResponse == null) // No secrets in the vault - { - return result.Values; - } - - foreach (SecretItem si in listResponse) - { - result[si.Identifier.Identifier] = si; - } - - while (!string.IsNullOrEmpty(listResponse.NextPageLink)) + var result = new Dictionary(StringComparer.InvariantCulture); + await foreach (var sp in this._keyVaultClients[regionIndex].SecretClient.GetPropertiesOfSecretVersionsAsync(secretName, cancellationToken).ConfigureAwait(false)) { - listResponse = await this._keyVaultClients[regionIndex].GetSecretVersionsNextAsync(listResponse.NextPageLink, cancellationToken).ConfigureAwait(false); - foreach (SecretItem si in listResponse) - { - result[si.Identifier.Identifier] = si; - } + result[sp.Id.ToString()] = sp; } return result.Values; @@ -414,13 +334,10 @@ await Task.WhenAll(t0, t1).ContinueWith(t => /// /// Deletes a secret from both vaults /// - /// The name of the secret in the given vault - /// Optional cancellation token - /// The deleted secret - public async Task DeleteSecretAsync(string secretName, CancellationToken cancellationToken = default(CancellationToken)) + public async Task DeleteSecretAsync(string secretName, CancellationToken cancellationToken = default) { - var t0 = this._keyVaultClients[0].DeleteSecretAsync(this._keyVaultClients[0].VaultUri, secretName, cancellationToken); - var t1 = this.Secondary ? this._keyVaultClients[1].DeleteSecretAsync(this._keyVaultClients[1].VaultUri, secretName, cancellationToken) : CompletedTask; + var t0 = this._keyVaultClients[0].SecretClient.StartDeleteSecretAsync(secretName, cancellationToken); + var t1 = this.Secondary ? this._keyVaultClients[1].SecretClient.StartDeleteSecretAsync(secretName, cancellationToken) : Task.FromResult(null); await Task.WhenAll(t0, t1).ContinueWith(t => { if (t0.IsFaulted && t1.IsFaulted) @@ -438,8 +355,6 @@ await Task.WhenAll(t0, t1).ContinueWith(t => throw new SecretException($"Failed to delete secret {secretName} from vault {this._keyVaultClients[1]}", t1.Exception); } }); - - return t0.Result; } #endregion @@ -447,37 +362,31 @@ await Task.WhenAll(t0, t1).ContinueWith(t => #region Certificates /// - /// Gets a certificate with private and public keys. Keys are exportable. - /// Returns a certificate with non-exportable keys + /// Gets a certificate with exportable private key by fetching its secret representation. /// - /// The name of the certificate in the given vault - /// The version of the certificate (optional) - /// Optional cancellation token - /// A response message containing the certificate with private key. - public async Task GetCertificateWithExportableKeysAsync(string certificateName, string certificateVersion = null, CancellationToken cancellationToken = default(CancellationToken)) + public async Task GetCertificateWithExportableKeysAsync(string certificateName, string certificateVersion = null, CancellationToken cancellationToken = default) { - SecretBundle s = await this.GetSecretAsync(certificateName, certificateVersion, cancellationToken); - var cert = new X509Certificate2(Convert.FromBase64String(s.Value), string.Empty, X509KeyStorageFlags.Exportable); - return cert; + KeyVaultSecret s = await this.GetSecretAsync(certificateName, certificateVersion, cancellationToken); + return X509CertificateLoader.LoadPkcs12(Convert.FromBase64String(s.Value), string.Empty, X509KeyStorageFlags.Exportable, Pkcs12LoaderLimits.Defaults); } /// - /// Gets a certificate. + /// Gets a certificate. Falls back to secondary on failure. /// - /// The name of the certificate in the given vault - /// The version of the certificate (optional) - /// Optional cancellation token - /// A response message containing the certificate - public async Task GetCertificateAsync(string certificateName, string certificateVersion = null, CancellationToken cancellationToken = default(CancellationToken)) + public async Task GetCertificateAsync(string certificateName, string certificateVersion = null, CancellationToken cancellationToken = default) { Queue exceptions = new Queue(); string vaults = ""; - certificateVersion = certificateVersion ?? string.Empty; foreach (var kv in this._keyVaultClients) { try { - return await kv.GetCertificateAsync(kv.VaultUri, certificateName, certificateVersion, cancellationToken).ConfigureAwait(false); + Response response; + if (string.IsNullOrEmpty(certificateVersion)) + response = await kv.CertificateClient.GetCertificateAsync(certificateName, cancellationToken).ConfigureAwait(false); + else + response = await kv.CertificateClient.GetCertificateVersionAsync(certificateName, certificateVersion, cancellationToken).ConfigureAwait(false); + return response.Value; } catch (Exception e) { @@ -489,41 +398,26 @@ await Task.WhenAll(t0, t1).ContinueWith(t => throw new SecretException($"Failed to get certificate {certificateName} from vault(s){vaults}", exceptions.ToArray()); } + /// + /// Gets the management policy for a certificate from the primary vault. + /// + public async Task GetCertificatePolicyAsync(string certificateName, CancellationToken cancellationToken = default) + { + var response = await this._keyVaultClients[0].CertificateClient.GetCertificatePolicyAsync(certificateName, cancellationToken).ConfigureAwait(false); + return response.Value; + } /// /// List all certificates from specified vault - /// This function will only look in single specified Azure Key Vault. It will not fallback to other region. /// - /// 0 - current region, 1 - other region - /// Optional progress update delegate - /// Optional cancellation token - /// IEnumerable of CertificateItem - public async Task> ListCertificatesAsync(int regionIndex = 0, ListOperationProgressUpdate listCertificatesProgressUpdate = null, CancellationToken cancellationToken = default(CancellationToken)) + public async Task> ListCertificatesAsync(int regionIndex = 0, ListOperationProgressUpdate listCertificatesProgressUpdate = null, CancellationToken cancellationToken = default) { Guard.ArgumentIsValidRegion(regionIndex, nameof(regionIndex)); Guard.ArgumentInRange(regionIndex, 0, this._keyVaultClients.Length - 1, nameof(regionIndex)); - var listResponse = await this._keyVaultClients[regionIndex].GetCertificatesAsync(this._keyVaultClients[regionIndex].VaultUri, Consts.ListCertificatesMaxResults, cancellationToken: cancellationToken).ConfigureAwait(false); - Dictionary result = new Dictionary(StringComparer.InvariantCulture); - if (listResponse == null) // No certificates in the vault - { - return result.Values; - } - - foreach (CertificateItem ci in listResponse) - { - result[ci.Identifier.Name] = ci; - } - - listCertificatesProgressUpdate?.Invoke(result.Count); - - while (!string.IsNullOrEmpty(listResponse.NextPageLink)) + var result = new Dictionary(StringComparer.InvariantCulture); + await foreach (var cp in this._keyVaultClients[regionIndex].CertificateClient.GetPropertiesOfCertificatesAsync(cancellationToken: cancellationToken).ConfigureAwait(false)) { - listResponse = await this._keyVaultClients[regionIndex].GetCertificatesNextAsync(listResponse.NextPageLink, cancellationToken).ConfigureAwait(false); - foreach (CertificateItem ci in listResponse) - { - result[ci.Identifier.Name] = ci; - } - + result[cp.Name] = cp; listCertificatesProgressUpdate?.Invoke(result.Count); } @@ -531,21 +425,26 @@ await Task.WhenAll(t0, t1).ContinueWith(t => } /// - /// Imports a new certificate version. If this is the first version, the certificate resource is created. + /// Imports a new certificate version into both vaults. /// - /// The name of the certificate - /// The certificate collection with the private key - /// The management policy for the certificate - /// The attributes of the certificate (optional) - /// Application-specific metadata in the form of key-value pairs - /// Optional cancellation token - /// A response message containing the imported certificate. - public async Task ImportCertificateAsync(string certificateName, X509Certificate2Collection certificateCollection, CertificatePolicy certificatePolicy, CertificateAttributes certificateAttributes = null, IDictionary tags = null, CancellationToken cancellationToken = default(CancellationToken)) + public async Task ImportCertificateAsync(string certificateName, X509Certificate2Collection certificateCollection, CertificatePolicy certificatePolicy, bool? enabled = null, IDictionary tags = null, CancellationToken cancellationToken = default) { string thumbprint = certificateCollection.FirstOrDefault()?.Thumbprint.ToLowerInvariant(); tags = Utils.AddMd5ChangedBy(tags, thumbprint, this.AuthenticatedUserName); - var t0 = this._keyVaultClients[0].ImportCertificateAsync(this._keyVaultClients[0].VaultUri, certificateName, certificateCollection, certificatePolicy, certificateAttributes, tags, cancellationToken); - var t1 = this.Secondary ? this._keyVaultClients[1].ImportCertificateAsync(this._keyVaultClients[1].VaultUri, certificateName, certificateCollection, certificatePolicy, certificateAttributes, tags, cancellationToken) : CompletedTask; + + byte[] pfxBytes = certificateCollection.Export(X509ContentType.Pkcs12); + var options = new ImportCertificateOptions(certificateName, pfxBytes) + { + Policy = certificatePolicy, + Enabled = enabled, + }; + if (tags != null) + { + foreach (var kvp in tags) options.Tags[kvp.Key] = kvp.Value; + } + + var t0 = this._keyVaultClients[0].CertificateClient.ImportCertificateAsync(options, cancellationToken); + var t1 = this.Secondary ? this._keyVaultClients[1].CertificateClient.ImportCertificateAsync(options, cancellationToken) : Task.FromResult>(null); await Task.WhenAll(t0, t1).ContinueWith(t => { if (t0.IsFaulted && t1.IsFaulted) @@ -563,20 +462,16 @@ await Task.WhenAll(t0, t1).ContinueWith(t => throw new SecretException($"Failed to import certificate {certificateName} to vault {this._keyVaultClients[1]}", t1.Exception); } }); - - return t0.Result; + return t0.Result.Value; } /// - /// Deletes a certificate from the specified vault. + /// Deletes a certificate from both vaults. /// - /// The name of the certificate in the given vault. - /// Optional cancellation token - /// The deleted certificate - public async Task DeleteCertificateAsync(string certificateName, CancellationToken cancellationToken = default(CancellationToken)) + public async Task DeleteCertificateAsync(string certificateName, CancellationToken cancellationToken = default) { - var t0 = this._keyVaultClients[0].DeleteCertificateAsync(this._keyVaultClients[0].VaultUri, certificateName, cancellationToken); - var t1 = this.Secondary ? this._keyVaultClients[1].DeleteCertificateAsync(this._keyVaultClients[1].VaultUri, certificateName, cancellationToken) : CompletedTask; + var t0 = this._keyVaultClients[0].CertificateClient.StartDeleteCertificateAsync(certificateName, cancellationToken); + var t1 = this.Secondary ? this._keyVaultClients[1].CertificateClient.StartDeleteCertificateAsync(certificateName, cancellationToken) : Task.FromResult(null); await Task.WhenAll(t0, t1).ContinueWith(t => { if (t0.IsFaulted && t1.IsFaulted) @@ -594,26 +489,41 @@ await Task.WhenAll(t0, t1).ContinueWith(t => throw new SecretException($"Failed to delete certificate {certificateName} from vault {this._keyVaultClients[1]}", t1.Exception); } }); - - return t0.Result; } /// - /// Updates a certificate + /// Updates certificate properties in both vaults. + /// Fetches the current version first so UpdateCertificatePropertiesAsync gets a versioned URI. /// - /// The name of the certificate in the given vault. - /// The certificate version (optional) - /// The certificate policy (optional) - /// The attributes of the certificate (optional) - /// Application-specific metadata in the form of key-value pairs - /// Optional cancellation token - /// A response message containing the updated certificate. - public async Task UpdateCertificateAsync(string certificateName, string certificateVersion = null, CertificatePolicy certificatePolicy = null, CertificateAttributes certificateAttributes = null, IDictionary tags = null, CancellationToken cancellationToken = default(CancellationToken)) + public async Task UpdateCertificateAsync(string certificateName, string certificateVersion = null, bool? enabled = null, DateTimeOffset? expires = null, DateTimeOffset? notBefore = null, IDictionary tags = null, CancellationToken cancellationToken = default) { tags = Utils.AddMd5ChangedBy(tags, null, this.AuthenticatedUserName); - certificateVersion = certificateVersion ?? string.Empty; - var t0 = this._keyVaultClients[0].UpdateCertificateAsync(this._keyVaultClients[0].VaultUri, certificateName, certificateVersion, certificatePolicy, certificateAttributes, tags, cancellationToken); - var t1 = this.Secondary ? this._keyVaultClients[1].UpdateCertificateAsync(this._keyVaultClients[1].VaultUri, certificateName, certificateVersion, certificatePolicy, certificateAttributes, tags, cancellationToken) : CompletedTask; + string version = string.IsNullOrEmpty(certificateVersion) ? null : certificateVersion; + + Response curr0; + if (string.IsNullOrEmpty(version)) + curr0 = await this._keyVaultClients[0].CertificateClient.GetCertificateAsync(certificateName, cancellationToken).ConfigureAwait(false); + else + curr0 = await this._keyVaultClients[0].CertificateClient.GetCertificateVersionAsync(certificateName, version, cancellationToken).ConfigureAwait(false); + + var props0 = curr0.Value.Properties; + ApplyToCertificateProperties(props0, tags, enabled, expires, notBefore); + var t0 = this._keyVaultClients[0].CertificateClient.UpdateCertificatePropertiesAsync(props0, cancellationToken); + + Task> t1 = Task.FromResult>(null); + if (this.Secondary) + { + Response curr1; + if (string.IsNullOrEmpty(version)) + curr1 = await this._keyVaultClients[1].CertificateClient.GetCertificateAsync(certificateName, cancellationToken).ConfigureAwait(false); + else + curr1 = await this._keyVaultClients[1].CertificateClient.GetCertificateVersionAsync(certificateName, version, cancellationToken).ConfigureAwait(false); + + var props1 = curr1.Value.Properties; + ApplyToCertificateProperties(props1, tags, enabled, expires, notBefore); + t1 = this._keyVaultClients[1].CertificateClient.UpdateCertificatePropertiesAsync(props1, cancellationToken); + } + await Task.WhenAll(t0, t1).ContinueWith(t => { if (t0.IsFaulted && t1.IsFaulted) @@ -631,22 +541,28 @@ await Task.WhenAll(t0, t1).ContinueWith(t => throw new SecretException($"Failed to update certificate {certificateName} in vault {this._keyVaultClients[1]}", t1.Exception); } }); + return t0.Result.Value; + } - return t0.Result; + private static void ApplyToCertificateProperties(CertificateProperties props, IDictionary tags, bool? enabled, DateTimeOffset? expires, DateTimeOffset? notBefore) + { + props.Enabled = enabled; + props.ExpiresOn = expires; + props.NotBefore = notBefore; + props.Tags.Clear(); + if (tags != null) + { + foreach (var kvp in tags) props.Tags[kvp.Key] = kvp.Value; + } } /// - /// Updates the policy for a certificate. Set appropriate members in the certificatePolicy that must be updated. Leave - /// others as null. + /// Updates the policy for a certificate in both vaults. /// - /// The name of the certificate in the given vault. - /// The policy for the certificate. - /// Optional cancellation token - /// A response message containing the updated certificate policy. - public async Task UpdateCertificatePolicyAsync(string certificateName, CertificatePolicy certificatePolicy, CancellationToken cancellationToken = default(CancellationToken)) + public async Task UpdateCertificatePolicyAsync(string certificateName, CertificatePolicy certificatePolicy, CancellationToken cancellationToken = default) { - var t0 = this._keyVaultClients[0].UpdateCertificatePolicyAsync(this._keyVaultClients[0].VaultUri, certificateName, certificatePolicy, cancellationToken); - var t1 = this.Secondary ? this._keyVaultClients[1].UpdateCertificatePolicyAsync(this._keyVaultClients[1].VaultUri, certificateName, certificatePolicy, cancellationToken) : CompletedTask; + var t0 = this._keyVaultClients[0].CertificateClient.UpdateCertificatePolicyAsync(certificateName, certificatePolicy, cancellationToken); + var t1 = this.Secondary ? this._keyVaultClients[1].CertificateClient.UpdateCertificatePolicyAsync(certificateName, certificatePolicy, cancellationToken) : Task.FromResult>(null); await Task.WhenAll(t0, t1).ContinueWith(t => { if (t0.IsFaulted && t1.IsFaulted) @@ -664,42 +580,22 @@ await Task.WhenAll(t0, t1).ContinueWith(t => throw new SecretException($"Failed to update certificate policy for {certificateName} in vault {this._keyVaultClients[1]}", t1.Exception); } }); - - return t0.Result; + return t0.Result.Value; } /// /// List the versions of a certificate. /// - /// The name of the certificate - /// 0 - current region, 1 - other region - /// Optional cancellation token - /// IEnumerable of CertificateItem - public async Task> GetCertificateVersionsAsync(string certificateName, int regionIndex = 0, CancellationToken cancellationToken = default(CancellationToken)) + public async Task> GetCertificateVersionsAsync(string certificateName, int regionIndex = 0, CancellationToken cancellationToken = default) { Guard.ArgumentNotNullOrWhitespace(certificateName, nameof(certificateName)); Guard.ArgumentIsValidRegion(regionIndex, nameof(regionIndex)); Guard.ArgumentInRange(regionIndex, 0, this._keyVaultClients.Length - 1, nameof(regionIndex)); - var listResponse = await this._keyVaultClients[regionIndex].GetCertificateVersionsAsync(this._keyVaultClients[regionIndex].VaultUri, certificateName, Consts.GetCertificateVersionsMaxResults, cancellationToken: cancellationToken).ConfigureAwait(false); - Dictionary result = new Dictionary(StringComparer.InvariantCulture); - if (listResponse == null) // No certificates in the vault - { - return result.Values; - } - - foreach (CertificateItem ci in listResponse) - { - result[ci.Identifier.Identifier] = ci; - } - - while (!string.IsNullOrEmpty(listResponse.NextPageLink)) + var result = new Dictionary(StringComparer.InvariantCulture); + await foreach (var cp in this._keyVaultClients[regionIndex].CertificateClient.GetPropertiesOfCertificateVersionsAsync(certificateName, cancellationToken).ConfigureAwait(false)) { - listResponse = await this._keyVaultClients[regionIndex].GetCertificateVersionsNextAsync(listResponse.NextPageLink, cancellationToken).ConfigureAwait(false); - foreach (CertificateItem ci in listResponse) - { - result[ci.Identifier.Identifier] = ci; - } + result[cp.Id.ToString()] = cp; } return result.Values; @@ -707,4 +603,4 @@ await Task.WhenAll(t0, t1).ContinueWith(t => #endregion } -} \ No newline at end of file +} diff --git a/Vault/Library/VaultAccess.cs b/Vault/Library/VaultAccess.cs index d5a8524e..b01e5d38 100644 --- a/Vault/Library/VaultAccess.cs +++ b/Vault/Library/VaultAccess.cs @@ -137,6 +137,8 @@ private IPublicClientApplication GetPublicClientApp() return this._publicClientApp; } + private static bool IsGuid(string value) => Guid.TryParse(value, out _); + protected override async Task AcquireTokenSilentAsync(string[] scopes, string userAlias = "") { var app = this.GetPublicClientApp(); @@ -177,6 +179,10 @@ protected override async Task AcquireTokenInternalAsync(st { builder = builder.WithLoginHint($"{userAlias}"); } + else if (IsGuid(this.DomainHint)) + { + builder = builder.WithLoginHint(userAlias); + } else { builder = builder.WithLoginHint($"{userAlias}@{this.DomainHint}"); @@ -335,4 +341,4 @@ protected override async Task AcquireTokenInternalAsync(st public override string ToString() => $"{nameof(VaultAccessClientCertificate)}"; } -} \ No newline at end of file +} diff --git a/Vault/Library/VaultAccessTokenCredential.cs b/Vault/Library/VaultAccessTokenCredential.cs new file mode 100644 index 00000000..c7ab01f8 --- /dev/null +++ b/Vault/Library/VaultAccessTokenCredential.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +namespace Microsoft.Vault.Library +{ + using System; + using System.Collections.Generic; + using System.Threading; + using System.Threading.Tasks; + using Azure.Core; + using Microsoft.Identity.Client; + + /// + /// Bridges the existing MSAL-based auth to the + /// contract required by Azure SDK Track-2 clients. + /// Tries each in order, returning the first successful token. + /// + internal sealed class VaultAccessTokenCredential : TokenCredential + { + private readonly VaultAccess[] _vaultAccesses; + private readonly string _userAliasType; + private readonly string _vaultName; + private readonly Action _onAuthenticated; + + internal VaultAccessTokenCredential(VaultAccess[] vaultAccesses, string userAliasType, string vaultName, Action onAuthenticated) + { + _vaultAccesses = vaultAccesses; + _userAliasType = userAliasType ?? string.Empty; + _vaultName = vaultName; + _onAuthenticated = onAuthenticated; + } + + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + => GetTokenAsync(requestContext, cancellationToken).GetAwaiter().GetResult(); + + public override async ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + Queue exceptions = new Queue(); + string vaultAccessTypes = ""; + foreach (VaultAccess va in _vaultAccesses) + { + try + { + AuthenticationResult result = await va.AcquireTokenAsync(requestContext.Scopes, _userAliasType).ConfigureAwait(false); + if (result.Account == null) + throw new VaultAccessException("The authentication result doesn't include account information"); + _onAuthenticated?.Invoke(result.Account.Username ?? _userAliasType); + return new AccessToken(result.AccessToken, result.ExpiresOn); + } + catch (Exception e) + { + vaultAccessTypes += $" {va}"; + exceptions.Enqueue(e); + } + } + throw new VaultAccessException($"Failed to get access to {_vaultName} with all possible vault access type(s){vaultAccessTypes}", exceptions.ToArray()); + } + } +} diff --git a/Vault/Library/VaultLibrary.csproj b/Vault/Library/VaultLibrary.csproj index c0ac3675..54a1ee19 100644 --- a/Vault/Library/VaultLibrary.csproj +++ b/Vault/Library/VaultLibrary.csproj @@ -1,6 +1,6 @@  - net8.0-windows10.0.17763.0 + net10.0-windows10.0.17763.0 Library Microsoft.Vault.Library Microsoft.Vault.Library @@ -10,6 +10,7 @@ True key.snk + $(MSBuildWarningsAsMessages);MSB3277 true @@ -17,10 +18,10 @@ true - + + - diff --git a/release.md b/release.md index 65f20b91..a353ae29 100644 --- a/release.md +++ b/release.md @@ -1,11 +1,45 @@ -# Release Process +# Release Process (ClickOnce + GitHub Pages) -## Steps +## Prerequisites -1. Generate a formatted git tag from the desired state of `main` and push it to GitHub by using the `tag_and_push.ps1` script in the root of this repo: +1. GitHub Pages is enabled for this repository and publishes from branch `gh-pages`. +2. The release workflow has `contents: write` permission (configured in `.github/workflows/release.yml`). +3. Repository has a valid ClickOnce publish profile (`Vault/Explorer/Properties/PublishProfiles/ClickOnceProfile.pubxml`). +4. For local publishing, use **Visual Studio 2022 17.14+** (MSBuild requirement for .NET 10 ClickOnce). - ```text +## Automated release flow + +1. Tag the commit you want to release (script helper): + + ```powershell .\tag_and_push.ps1 ``` -2. Generate release on GitHub via UI, referencing [tag](https://github.com/reysic/AzureKeyVaultExplorer/tags). + This creates and pushes a `v*` tag, which triggers `.github/workflows/release.yml`. + +2. Workflow executes `release.ps1`: + - restores and publishes VaultExplorer with the ClickOnce profile + - uses tag version as `ApplicationVersion` + - updates `gh-pages` branch with `Application Files` and `VaultExplorer.application` + - initializes `gh-pages` automatically if it does not yet exist + - skips commit/push when no deployment content changed + +3. Users install/update from: + + ```text + https://reysic.github.io/AzureKeyVaultExplorer + ``` + +## Manual fallback + +If needed, run locally: + +```powershell +.\release.ps1 +``` + +Build-only validation (no gh-pages push): + +```powershell +.\release.ps1 -OnlyBuild +``` diff --git a/release.ps1 b/release.ps1 index 3277bb7d..778cbb63 100644 --- a/release.ps1 +++ b/release.ps1 @@ -7,6 +7,7 @@ param ( $appName = 'VaultExplorer' $projDir = 'Vault\Explorer' +$installManifest = "$appName.application" Set-StrictMode -Version 2.0 $ErrorActionPreference = 'Stop' @@ -18,7 +19,20 @@ Write-Output "Working directory: $workingDir" $msBuildPath = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" ` -latest -requires Microsoft.Component.MSBuild -find MSBuild\**\Bin\MSBuild.exe ` -prerelease | Select-Object -First 1 +if ([string]::IsNullOrWhiteSpace($msBuildPath)) { + throw 'MSBuild was not found. Use the GitHub Actions release workflow on windows-latest.' +} + +$msBuildVersionText = (& $msBuildPath -version -nologo | Select-Object -Last 1).Trim() Write-Output "MSBuild: $((Get-Command $msBuildPath).Path)" +Write-Output "MSBuild version: $msBuildVersionText" + +$minimumSupportedVersion = [Version]'17.14.0' +[Version]$resolvedMsBuildVersion = $null +if (-not [Version]::TryParse($msBuildVersionText, [ref]$resolvedMsBuildVersion) -or + $resolvedMsBuildVersion -lt $minimumSupportedVersion) { + throw "MSBuild $minimumSupportedVersion+ is required for .NET 10 ClickOnce publishing. Current: '$msBuildVersionText'. Use GitHub Actions release workflow." +} # Load current Git tag. $tag = $(git describe --tags) @@ -38,17 +52,15 @@ if (Test-Path $outDir) { # Publish the application. Push-Location $projDir try { - Write-Output 'Restoring:' - dotnet restore -r win-x64 - Write-Output 'Publishing:' - $msBuildVerbosityArg = '/v:m' - if ($env:CI) { - $msBuildVerbosityArg = '' - } - & $msBuildPath /target:publish /p:PublishProfile=ClickOnceProfile ` + Write-Output 'Restoring + Publishing:' + & $msBuildPath /restore /target:publish ` + /p:RuntimeIdentifier=win-x64 ` /p:ApplicationVersion=$version /p:Configuration=Release ` - /p:PublishDir=$publishDir ` - $msBuildVerbosityArg + /p:PublishProfile=ClickOnceProfile ` + /p:PublishDir=$publishDir /v:m + if ($LASTEXITCODE -ne 0) { + throw "MSBuild publish failed with exit code $LASTEXITCODE" + } # Measure publish size. $publishSize = (Get-ChildItem -Path "$publishDir/Application Files" -Recurse | @@ -66,8 +78,37 @@ if ($OnlyBuild) { # Clone `gh-pages` branch. $ghPagesDir = 'gh-pages' if (-Not (Test-Path $ghPagesDir)) { - git clone $(git config --get remote.origin.url) -b gh-pages ` - --depth 1 --single-branch $ghPagesDir + $remoteUrl = git config --get remote.origin.url + $hasGhPages = $true + try { + git ls-remote --heads $remoteUrl gh-pages | Out-Null + $head = git ls-remote --heads $remoteUrl gh-pages + if ([string]::IsNullOrWhiteSpace($head)) { + $hasGhPages = $false + } + } + catch { + $hasGhPages = $false + } + + if ($hasGhPages) { + git clone $remoteUrl -b gh-pages --depth 1 --single-branch $ghPagesDir + } + else { + git clone $remoteUrl $ghPagesDir + Push-Location $ghPagesDir + try { + git checkout --orphan gh-pages + git rm -rf . | Out-Null + Set-Content -Path index.html -Value "

$appName

" -Encoding UTF8 + git add index.html + git commit -m "Initialize gh-pages branch" + git push origin gh-pages + } + finally { + Pop-Location + } + } } Push-Location $ghPagesDir @@ -83,17 +124,30 @@ try { # Copy new application files. Write-Output 'Copying new files...' - Copy-Item -Path "../$outDir/Application Files", "../$outDir/$appName.application" ` + Copy-Item -Path "../$outDir/Application Files", "../$outDir/$installManifest" ` -Destination . -Recurse + # Ensure GitHub Pages root always points to this fork's current ClickOnce manifest. + Set-Content -Path 'index.html' -Encoding UTF8 -Value @" + + +Redirecting to $installManifest + + +"@ + # Stage and commit. Write-Output 'Staging...' git add -A - Write-Output 'Committing...' - git commit -m "update to v$version" - - # Push. - git push + $pending = git status --porcelain + if ([string]::IsNullOrWhiteSpace($pending)) { + Write-Output 'No publish changes detected; skipping commit/push.' + } + else { + Write-Output 'Committing...' + git commit -m "update to v$version" + git push + } } finally { Pop-Location -} \ No newline at end of file +}