diff --git a/.claude/agents/winapp.md b/.claude/agents/winapp.md index afa1ff77..9d5a097d 100644 --- a/.claude/agents/winapp.md +++ b/.claude/agents/winapp.md @@ -42,7 +42,10 @@ Does the project already have an appxmanifest.xml? │ └─ Is the exe separate from your app code? (Electron, sparse package testing) │ └─ winapp create-debug-identity (registers sparse package) ├─ Need to sign an existing MSIX or exe? - │ └─ winapp sign + │ ├─ With a local dev/CA certificate (PFX)? + │ │ └─ winapp sign + │ └─ With Azure Trusted Signing (cloud-managed identity, no local PFX)? + │ └─ winapp az-sign └─ Need to run a Windows SDK tool directly (makeappx, signtool, makepri)? └─ winapp tool @@ -165,6 +168,18 @@ Want to inspect or interact with a running app's UI? - `--password ` — certificate password - `--timestamp ` — timestamp server URL (recommended for production to stay valid after cert expires) +### `winapp az-sign ` +**Purpose:** Code-sign an exe, MSIX, or MSIX bundle using Azure Trusted Signing (a cloud-managed signing identity — no local PFX). +**When to use:** For production signing when the certificate is managed in Azure rather than as a local PFX file. Works in CI/CD and interactively. +**Key options:** +- `--subscription ` (`-s`) — Azure subscription ID (prompts if omitted and multiple exist) +- `--resource-group ` (`-r`) — resource group to narrow down signing accounts +- `--account ` — signing account name (requires `--resource-group`) +- `--profile ` (`-p`) — certificate profile name (requires `--account`) +- `--metadata-file ` (`-m`) — reuse an existing `metadata.json`, skipping all prompting +**Auth:** Uses `DefaultAzureCredential`. For CI/CD set `AZURE_TENANT_ID`/`AZURE_CLIENT_ID`/`AZURE_CLIENT_SECRET` (or OIDC/managed identity); interactively falls back to `az login`. +**Requires:** An Azure Trusted Signing account + certificate profile, and the Trusted Signing Certificate Profile Signer role. + ### `winapp manifest generate [directory]` **Purpose:** Create an `appxmanifest.xml` without full project setup. **When to use:** When you only need a manifest and image assets, without SDK installation or config file creation. diff --git a/.claude/skills/winapp-signing/SKILL.md b/.claude/skills/winapp-signing/SKILL.md index 4624e1ff..91eac62e 100644 --- a/.claude/skills/winapp-signing/SKILL.md +++ b/.claude/skills/winapp-signing/SKILL.md @@ -9,6 +9,7 @@ Use this skill when: - **Generating a development certificate** for local MSIX signing and testing - **Installing (trusting) a certificate** on a machine so MSIX packages can be installed - **Signing an MSIX package or executable** for distribution +- **Signing with Azure Trusted Signing** (cloud-managed signing identity) via `winapp az-sign` ## Prerequisites @@ -80,6 +81,25 @@ When packaging multiple architectures into an `.msixbundle`, only the bundle nee Note: The `package` command can sign automatically when you pass `--cert`, so you often don't need `sign` separately. +### Sign with Azure Trusted Signing (cloud signing) + +For production-grade signing without managing a PFX file, use `winapp az-sign` to sign with [Azure Trusted Signing](https://learn.microsoft.com/azure/trusted-signing/). The signing identity (certificate) is managed in Azure, so no private key ever lives on the machine. + +```powershell +# Interactive: discover subscription, account, and profile (prompts for any not provided) +winapp az-sign ./app.msix + +# Fully specified — no prompting (ideal for CI/CD) +winapp az-sign ./app.msix --subscription --resource-group --account --profile + +# Reuse an existing metadata.json (skips all discovery/prompting) +winapp az-sign ./app.msix --metadata-file ./metadata.json +``` + +**Authentication:** `az-sign` uses Azure's standard credential chain (`DefaultAzureCredential`). In CI/CD, set `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, and `AZURE_CLIENT_SECRET` (or GitHub Actions OIDC / managed identity). Interactively, if no credentials are found and the Azure CLI is installed, `az-sign` runs `az login` for you. + +**Prerequisites:** An Azure Trusted Signing account and certificate profile (created in the Azure portal after identity validation), plus a role assignment granting your identity the **Trusted Signing Certificate Profile Signer** role. + ## Recommended workflow 1. **Generate cert** — `winapp cert generate` (auto-infers publisher from manifest) @@ -108,6 +128,8 @@ Note: The `package` command can sign automatically when you pass `--cert`, so yo | "Certificate not trusted" | Cert not installed on machine | `winapp cert install ./devcert.pfx` (admin) | | "Certificate file already exists" | `devcert.pfx` already present | Use `--if-exists overwrite` or `--if-exists skip` | | Signature invalid after time passes | No timestamp used during signing | Re-sign with `--timestamp http://timestamp.digicert.com` | +| `az-sign` fails with "No credentials found" | No Azure auth in environment | Run `az login`, or set `AZURE_TENANT_ID`/`AZURE_CLIENT_ID`/`AZURE_CLIENT_SECRET` for CI/CD | +| `az-sign` "No Trusted Signing accounts found" | No account in the subscription/resource group | Create a Trusted Signing account and certificate profile in the Azure portal | ## Command Reference @@ -181,3 +203,23 @@ Code-sign an MSIX package or executable. Example: winapp sign ./app.msix ./devce |--------|-------------|---------| | `--password` | Certificate password | `password` | | `--timestamp` | Timestamp server URL | (none) | + +### `winapp az-sign` + +Code-sign a file using Azure Trusted Signing. Signs executables, MSIX packages, or MSIX bundles using a cloud-managed signing identity. Example: winapp az-sign ./app.msix + +#### Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `` | Yes | Path to the file to sign (exe, msix, or msixbundle) | + +#### Options + +| Option | Description | Default | +|--------|-------------|---------| +| `--account` | Signing account name. Must be used with --resource-group | (none) | +| `--metadata-file` | Path to an existing metadata.json file. Skips all prompting and uses this file directly for signing. | (none) | +| `--profile` | Certificate profile name. Must be used with --account | (none) | +| `--resource-group` | Resource group to narrow down signing accounts | (none) | +| `--subscription` | Azure subscription ID to use. If not provided and multiple subscriptions exist, you will be prompted. | (none) | diff --git a/.github/plugin/agents/winapp.agent.md b/.github/plugin/agents/winapp.agent.md index b31c658e..d6a11f1e 100644 --- a/.github/plugin/agents/winapp.agent.md +++ b/.github/plugin/agents/winapp.agent.md @@ -43,7 +43,10 @@ Does the project already have an appxmanifest.xml? │ └─ Is the exe separate from your app code? (Electron, sparse package testing) │ └─ winapp create-debug-identity (registers sparse package) ├─ Need to sign an existing MSIX or exe? - │ └─ winapp sign + │ ├─ With a local dev/CA certificate (PFX)? + │ │ └─ winapp sign + │ └─ With Azure Trusted Signing (cloud-managed identity, no local PFX)? + │ └─ winapp az-sign └─ Need to run a Windows SDK tool directly (makeappx, signtool, makepri)? └─ winapp tool @@ -166,6 +169,18 @@ Want to inspect or interact with a running app's UI? - `--password ` — certificate password - `--timestamp ` — timestamp server URL (recommended for production to stay valid after cert expires) +### `winapp az-sign ` +**Purpose:** Code-sign an exe, MSIX, or MSIX bundle using Azure Trusted Signing (a cloud-managed signing identity — no local PFX). +**When to use:** For production signing when the certificate is managed in Azure rather than as a local PFX file. Works in CI/CD and interactively. +**Key options:** +- `--subscription ` (`-s`) — Azure subscription ID (prompts if omitted and multiple exist) +- `--resource-group ` (`-r`) — resource group to narrow down signing accounts +- `--account ` — signing account name (requires `--resource-group`) +- `--profile ` (`-p`) — certificate profile name (requires `--account`) +- `--metadata-file ` (`-m`) — reuse an existing `metadata.json`, skipping all prompting +**Auth:** Uses `DefaultAzureCredential`. For CI/CD set `AZURE_TENANT_ID`/`AZURE_CLIENT_ID`/`AZURE_CLIENT_SECRET` (or OIDC/managed identity); interactively falls back to `az login`. +**Requires:** An Azure Trusted Signing account + certificate profile, and the Trusted Signing Certificate Profile Signer role. + ### `winapp manifest generate [directory]` **Purpose:** Create an `appxmanifest.xml` without full project setup. **When to use:** When you only need a manifest and image assets, without SDK installation or config file creation. diff --git a/.github/plugin/skills/winapp-cli/signing/SKILL.md b/.github/plugin/skills/winapp-cli/signing/SKILL.md index 4624e1ff..91eac62e 100644 --- a/.github/plugin/skills/winapp-cli/signing/SKILL.md +++ b/.github/plugin/skills/winapp-cli/signing/SKILL.md @@ -9,6 +9,7 @@ Use this skill when: - **Generating a development certificate** for local MSIX signing and testing - **Installing (trusting) a certificate** on a machine so MSIX packages can be installed - **Signing an MSIX package or executable** for distribution +- **Signing with Azure Trusted Signing** (cloud-managed signing identity) via `winapp az-sign` ## Prerequisites @@ -80,6 +81,25 @@ When packaging multiple architectures into an `.msixbundle`, only the bundle nee Note: The `package` command can sign automatically when you pass `--cert`, so you often don't need `sign` separately. +### Sign with Azure Trusted Signing (cloud signing) + +For production-grade signing without managing a PFX file, use `winapp az-sign` to sign with [Azure Trusted Signing](https://learn.microsoft.com/azure/trusted-signing/). The signing identity (certificate) is managed in Azure, so no private key ever lives on the machine. + +```powershell +# Interactive: discover subscription, account, and profile (prompts for any not provided) +winapp az-sign ./app.msix + +# Fully specified — no prompting (ideal for CI/CD) +winapp az-sign ./app.msix --subscription --resource-group --account --profile + +# Reuse an existing metadata.json (skips all discovery/prompting) +winapp az-sign ./app.msix --metadata-file ./metadata.json +``` + +**Authentication:** `az-sign` uses Azure's standard credential chain (`DefaultAzureCredential`). In CI/CD, set `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, and `AZURE_CLIENT_SECRET` (or GitHub Actions OIDC / managed identity). Interactively, if no credentials are found and the Azure CLI is installed, `az-sign` runs `az login` for you. + +**Prerequisites:** An Azure Trusted Signing account and certificate profile (created in the Azure portal after identity validation), plus a role assignment granting your identity the **Trusted Signing Certificate Profile Signer** role. + ## Recommended workflow 1. **Generate cert** — `winapp cert generate` (auto-infers publisher from manifest) @@ -108,6 +128,8 @@ Note: The `package` command can sign automatically when you pass `--cert`, so yo | "Certificate not trusted" | Cert not installed on machine | `winapp cert install ./devcert.pfx` (admin) | | "Certificate file already exists" | `devcert.pfx` already present | Use `--if-exists overwrite` or `--if-exists skip` | | Signature invalid after time passes | No timestamp used during signing | Re-sign with `--timestamp http://timestamp.digicert.com` | +| `az-sign` fails with "No credentials found" | No Azure auth in environment | Run `az login`, or set `AZURE_TENANT_ID`/`AZURE_CLIENT_ID`/`AZURE_CLIENT_SECRET` for CI/CD | +| `az-sign` "No Trusted Signing accounts found" | No account in the subscription/resource group | Create a Trusted Signing account and certificate profile in the Azure portal | ## Command Reference @@ -181,3 +203,23 @@ Code-sign an MSIX package or executable. Example: winapp sign ./app.msix ./devce |--------|-------------|---------| | `--password` | Certificate password | `password` | | `--timestamp` | Timestamp server URL | (none) | + +### `winapp az-sign` + +Code-sign a file using Azure Trusted Signing. Signs executables, MSIX packages, or MSIX bundles using a cloud-managed signing identity. Example: winapp az-sign ./app.msix + +#### Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `` | Yes | Path to the file to sign (exe, msix, or msixbundle) | + +#### Options + +| Option | Description | Default | +|--------|-------------|---------| +| `--account` | Signing account name. Must be used with --resource-group | (none) | +| `--metadata-file` | Path to an existing metadata.json file. Skips all prompting and uses this file directly for signing. | (none) | +| `--profile` | Certificate profile name. Must be used with --account | (none) | +| `--resource-group` | Resource group to narrow down signing accounts | (none) | +| `--subscription` | Azure subscription ID to use. If not provided and multiple subscriptions exist, you will be prompted. | (none) | diff --git a/docs/cli-schema.json b/docs/cli-schema.json index f18bc2c1..e1eaf28d 100644 --- a/docs/cli-schema.json +++ b/docs/cli-schema.json @@ -50,6 +50,129 @@ } }, "subcommands": { + "az-sign": { + "description": "Code-sign a file using Azure Trusted Signing. Signs executables, MSIX packages, or MSIX bundles using a cloud-managed signing identity. Example: winapp az-sign ./app.msix", + "hidden": false, + "arguments": { + "file-path": { + "description": "Path to the file to sign (exe, msix, or msixbundle)", + "order": 0, + "hidden": false, + "valueType": "System.IO.FileInfo", + "hasDefaultValue": false, + "arity": { + "minimum": 1, + "maximum": 1 + } + } + }, + "options": { + "--account": { + "description": "Signing account name. Must be used with --resource-group", + "hidden": false, + "valueType": "System.String", + "hasDefaultValue": false, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--metadata-file": { + "description": "Path to an existing metadata.json file. Skips all prompting and uses this file directly for signing.", + "hidden": false, + "aliases": [ + "-m" + ], + "valueType": "System.IO.FileInfo", + "hasDefaultValue": false, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--profile": { + "description": "Certificate profile name. Must be used with --account", + "hidden": false, + "aliases": [ + "-p" + ], + "valueType": "System.String", + "hasDefaultValue": false, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--quiet": { + "description": "Suppress progress messages", + "hidden": false, + "aliases": [ + "-q" + ], + "valueType": "System.Boolean", + "hasDefaultValue": true, + "defaultValue": false, + "arity": { + "minimum": 0, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--resource-group": { + "description": "Resource group to narrow down signing accounts", + "hidden": false, + "aliases": [ + "-r" + ], + "valueType": "System.String", + "hasDefaultValue": false, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--subscription": { + "description": "Azure subscription ID to use. If not provided and multiple subscriptions exist, you will be prompted.", + "hidden": false, + "aliases": [ + "-s" + ], + "valueType": "System.String", + "hasDefaultValue": false, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--verbose": { + "description": "Enable verbose output", + "hidden": false, + "aliases": [ + "-v" + ], + "valueType": "System.Boolean", + "hasDefaultValue": true, + "defaultValue": false, + "arity": { + "minimum": 0, + "maximum": 1 + }, + "required": false, + "recursive": false + } + } + }, "cert": { "description": "Manage development certificates for code signing. Use 'cert generate' to create a self-signed certificate for testing, or 'cert install' (requires elevation) to trust an existing certificate on this machine.", "hidden": false, diff --git a/docs/fragments/skills/winapp-cli/signing.md b/docs/fragments/skills/winapp-cli/signing.md index 0a967772..68b4530a 100644 --- a/docs/fragments/skills/winapp-cli/signing.md +++ b/docs/fragments/skills/winapp-cli/signing.md @@ -4,6 +4,7 @@ Use this skill when: - **Generating a development certificate** for local MSIX signing and testing - **Installing (trusting) a certificate** on a machine so MSIX packages can be installed - **Signing an MSIX package or executable** for distribution +- **Signing with Azure Trusted Signing** (cloud-managed signing identity) via `winapp az-sign` ## Prerequisites @@ -75,6 +76,25 @@ When packaging multiple architectures into an `.msixbundle`, only the bundle nee Note: The `package` command can sign automatically when you pass `--cert`, so you often don't need `sign` separately. +### Sign with Azure Trusted Signing (cloud signing) + +For production-grade signing without managing a PFX file, use `winapp az-sign` to sign with [Azure Trusted Signing](https://learn.microsoft.com/azure/trusted-signing/). The signing identity (certificate) is managed in Azure, so no private key ever lives on the machine. + +```powershell +# Interactive: discover subscription, account, and profile (prompts for any not provided) +winapp az-sign ./app.msix + +# Fully specified — no prompting (ideal for CI/CD) +winapp az-sign ./app.msix --subscription --resource-group --account --profile + +# Reuse an existing metadata.json (skips all discovery/prompting) +winapp az-sign ./app.msix --metadata-file ./metadata.json +``` + +**Authentication:** `az-sign` uses Azure's standard credential chain (`DefaultAzureCredential`). In CI/CD, set `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, and `AZURE_CLIENT_SECRET` (or GitHub Actions OIDC / managed identity). Interactively, if no credentials are found and the Azure CLI is installed, `az-sign` runs `az login` for you. + +**Prerequisites:** An Azure Trusted Signing account and certificate profile (created in the Azure portal after identity validation), plus a role assignment granting your identity the **Trusted Signing Certificate Profile Signer** role. + ## Recommended workflow 1. **Generate cert** — `winapp cert generate` (auto-infers publisher from manifest) @@ -103,3 +123,5 @@ Note: The `package` command can sign automatically when you pass `--cert`, so yo | "Certificate not trusted" | Cert not installed on machine | `winapp cert install ./devcert.pfx` (admin) | | "Certificate file already exists" | `devcert.pfx` already present | Use `--if-exists overwrite` or `--if-exists skip` | | Signature invalid after time passes | No timestamp used during signing | Re-sign with `--timestamp http://timestamp.digicert.com` | +| `az-sign` fails with "No credentials found" | No Azure auth in environment | Run `az login`, or set `AZURE_TENANT_ID`/`AZURE_CLIENT_ID`/`AZURE_CLIENT_SECRET` for CI/CD | +| `az-sign` "No Trusted Signing accounts found" | No account in the subscription/resource group | Create a Trusted Signing account and certificate profile in the Azure portal | diff --git a/docs/npm-usage.md b/docs/npm-usage.md index 51eb67f6..e312a0e3 100644 --- a/docs/npm-usage.md +++ b/docs/npm-usage.md @@ -59,6 +59,29 @@ Result returned by every command wrapper. These functions wrap native `winapp` CLI commands. All accept [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`). +### `azSign()` + +Code-sign a file using Azure Trusted Signing. Signs executables, MSIX packages, or MSIX bundles using a cloud-managed signing identity. Example: winapp az-sign ./app.msix + +```typescript +function azSign(options: AzSignOptions): Promise +``` + +**Options:** + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `filePath` | `string` | Yes | Path to the file to sign (exe, msix, or msixbundle) | +| `account` | `string \| undefined` | No | Signing account name. Must be used with --resource-group | +| `metadataFile` | `string \| undefined` | No | Path to an existing metadata.json file. Skips all prompting and uses this file directly for signing. | +| `profile` | `string \| undefined` | No | Certificate profile name. Must be used with --account | +| `resourceGroup` | `string \| undefined` | No | Resource group to narrow down signing accounts | +| `subscription` | `string \| undefined` | No | Azure subscription ID to use. If not provided and multiple subscriptions exist, you will be prompted. | + +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* + +--- + ### `certGenerate()` Create a self-signed certificate for local testing only. Publisher must match the manifest (auto-inferred if --manifest provided or Package.appxmanifest is in working directory). Output: devcert.pfx (default password: 'password'). For production, obtain a certificate from a trusted CA. Use 'cert install' to trust on this machine. @@ -1109,6 +1132,20 @@ ManifestTemplates values. type ManifestTemplates = "packaged" | "sparse" ``` +### `AzSignOptions` + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `filePath` | `string` | Yes | Path to the file to sign (exe, msix, or msixbundle) | +| `account` | `string \| undefined` | No | Signing account name. Must be used with --resource-group | +| `metadataFile` | `string \| undefined` | No | Path to an existing metadata.json file. Skips all prompting and uses this file directly for signing. | +| `profile` | `string \| undefined` | No | Certificate profile name. Must be used with --account | +| `resourceGroup` | `string \| undefined` | No | Resource group to narrow down signing accounts | +| `subscription` | `string \| undefined` | No | Azure subscription ID to use. If not provided and multiple subscriptions exist, you will be prompted. | +| `quiet` | `boolean \| undefined` | No | Suppress progress messages. | +| `verbose` | `boolean \| undefined` | No | Enable verbose output. | +| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | + ### `CertGenerateOptions` | Property | Type | Required | Description | diff --git a/docs/usage.md b/docs/usage.md index 5ce162ba..25c4ca7d 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -735,6 +735,49 @@ winapp sign ./bin/MyApp.exe --cert ./mycert.pfx --cert-password mypassword --- +### az-sign + +Code-sign a file (exe, MSIX, or MSIX bundle) using [Azure Trusted Signing](https://learn.microsoft.com/azure/trusted-signing/) — a cloud-managed signing identity, so no private key (PFX) ever lives on the local machine. + +```bash +winapp az-sign [options] +``` + +**Arguments:** + +- `file-path` - Path to the file to sign (exe, msix, or msixbundle) + +**Options:** + +- `--subscription`, `-s` - Azure subscription ID. If omitted and multiple exist, you are prompted +- `--resource-group`, `-r` - Resource group to narrow down signing accounts +- `--account` - Signing account name. Must be used with `--resource-group` +- `--profile`, `-p` - Certificate profile name. Must be used with `--account` +- `--metadata-file`, `-m` - Path to an existing `metadata.json`. Skips all prompting and signs directly + +**Authentication:** + +`az-sign` uses Azure's standard credential chain (`DefaultAzureCredential`). For CI/CD, set `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, and `AZURE_CLIENT_SECRET` (or use GitHub Actions OIDC / managed identity). Interactively, if no credentials are found and the Azure CLI is installed, `az-sign` runs `az login` for you. + +**Prerequisites:** + +An Azure Trusted Signing account and a certificate profile (created in the Azure portal after identity validation), plus the **Trusted Signing Certificate Profile Signer** role assigned to your identity. + +**Examples:** + +```bash +# Interactive — discover/select subscription, account, and profile +winapp az-sign ./app.msix + +# Fully specified — no prompting (ideal for CI/CD) +winapp az-sign ./app.msix --subscription --resource-group --account --profile + +# Reuse an existing metadata.json (skips discovery and prompting) +winapp az-sign ./app.msix --metadata-file ./metadata.json +``` + +--- + ### create-external-catalog Generate a `CodeIntegrityExternal.cat` catalog file containing hashes of executable files from specified directories. This catalog is used with the [TrustedLaunch](https://learn.microsoft.com/uwp/schemas/appxpackage/uapmanifestschema/element-trustedlaunch-trustedlaunch) flag in MSIX sparse package manifests ([AllowExternalContent](https://learn.microsoft.com/uwp/schemas/appxpackage/uapmanifestschema/element-uap10-allowexternalcontent)) to allow execution of external files not included in the package itself. diff --git a/scripts/generate-llm-docs.ps1 b/scripts/generate-llm-docs.ps1 index ff2735d4..069b3335 100644 --- a/scripts/generate-llm-docs.ps1 +++ b/scripts/generate-llm-docs.ps1 @@ -100,7 +100,7 @@ $SkillCommandMap = @{ "setup" = @("init", "restore", "update", "run") "package" = @("package", "create-external-catalog") "identity" = @("create-debug-identity") - "signing" = @("cert generate", "cert install", "cert info", "sign") + "signing" = @("cert generate", "cert install", "cert info", "sign", "az-sign") "manifest" = @("manifest generate", "manifest update-assets", "manifest add-alias") "troubleshoot" = @("get-winapp-path", "tool", "store") "frameworks" = @() # No auto-generated command sections — links to guides diff --git a/src/winapp-CLI/Directory.Packages.props b/src/winapp-CLI/Directory.Packages.props index 52640091..698f90fb 100644 --- a/src/winapp-CLI/Directory.Packages.props +++ b/src/winapp-CLI/Directory.Packages.props @@ -1,5 +1,6 @@ + @@ -13,4 +14,4 @@ - + \ No newline at end of file diff --git a/src/winapp-CLI/WinApp.Cli.Tests/AzSignCommandTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/AzSignCommandTests.cs new file mode 100644 index 00000000..e1c59d2f --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/AzSignCommandTests.cs @@ -0,0 +1,412 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Extensions.DependencyInjection; +using WinApp.Cli.Commands; +using WinApp.Cli.ConsoleTasks; +using WinApp.Cli.Services; + +namespace WinApp.Cli.Tests; + +[TestClass] +public class AzSignCommandTests : BaseCommandTests +{ + private FakeAzureAuthService _fakeAuthService = null!; + private FakeAzureSigningService _fakeSigningService = null!; + private FakeAzureSignToolService _fakeSignToolService = null!; + private AzSignCommand _command = null!; + + [TestInitialize] + public void Setup() + { + _fakeAuthService = (FakeAzureAuthService)GetRequiredService(); + _fakeSigningService = (FakeAzureSigningService)GetRequiredService(); + _fakeSignToolService = (FakeAzureSignToolService)GetRequiredService(); + _command = GetRequiredService(); + } + + protected override IServiceCollection ConfigureServices(IServiceCollection services) + { + return services + .AddSingleton() + .AddSingleton() + .AddSingleton(); + } + + [TestMethod] + public async Task AzSign_AccountWithoutResourceGroup_ReturnsError() + { + var filePath = Path.Combine(_tempDirectory.FullName, "test.exe"); + await File.WriteAllTextAsync(filePath, "MZ"); + + var result = await ParseAndInvokeWithCaptureAsync(_command, ["--account", "myaccount", filePath]); + + Assert.AreEqual(1, result); + var allOutput = ConsoleStdOut.ToString() + ConsoleStdErr.ToString(); + StringAssert.Contains(allOutput, "--account must be used with --resource-group"); + } + + [TestMethod] + public async Task AzSign_ProfileWithoutAccount_ReturnsError() + { + var filePath = Path.Combine(_tempDirectory.FullName, "test.exe"); + await File.WriteAllTextAsync(filePath, "MZ"); + + var result = await ParseAndInvokeWithCaptureAsync(_command, ["--profile", "myprofile", filePath]); + + Assert.AreEqual(1, result); + var allOutput = ConsoleStdOut.ToString() + ConsoleStdErr.ToString(); + StringAssert.Contains(allOutput, "--profile must be used with --account"); + } + + [TestMethod] + public async Task AzSign_AuthenticationFails_ReturnsError() + { + var filePath = Path.Combine(_tempDirectory.FullName, "test.exe"); + await File.WriteAllTextAsync(filePath, "MZ"); + + _fakeAuthService.ShouldFail = true; + _fakeAuthService.FailureMessage = "Azure authentication failed. No credentials found."; + + var result = await ParseAndInvokeWithCaptureAsync(_command, [filePath]); + + Assert.AreEqual(1, result); + var allOutput = ConsoleStdOut.ToString() + ConsoleStdErr.ToString(); + StringAssert.Contains(allOutput, "Azure authentication failed"); + } + + [TestMethod] + public async Task AzSign_NoSubscriptionsFound_ReturnsError() + { + var filePath = Path.Combine(_tempDirectory.FullName, "test.exe"); + await File.WriteAllTextAsync(filePath, "MZ"); + + _fakeSigningService.Subscriptions = []; + + var result = await ParseAndInvokeWithCaptureAsync(_command, [filePath]); + + Assert.AreEqual(1, result); + var allOutput = ConsoleStdOut.ToString() + ConsoleStdErr.ToString(); + StringAssert.Contains(allOutput, "No Azure subscriptions found"); + } + + [TestMethod] + public async Task AzSign_NoSigningAccountsFound_ReturnsError() + { + var filePath = Path.Combine(_tempDirectory.FullName, "test.exe"); + await File.WriteAllTextAsync(filePath, "MZ"); + + _fakeSigningService.Subscriptions = + [ + new AzureSubscription("sub-123", "Test Subscription") + ]; + _fakeSigningService.SigningAccounts = []; + + var result = await ParseAndInvokeWithCaptureAsync(_command, [filePath]); + + Assert.AreEqual(1, result); + var allOutput = ConsoleStdOut.ToString() + ConsoleStdErr.ToString(); + StringAssert.Contains(allOutput, "No Trusted Signing accounts found"); + } + + [TestMethod] + public async Task AzSign_NoProfilesFound_ReturnsError() + { + var filePath = Path.Combine(_tempDirectory.FullName, "test.exe"); + await File.WriteAllTextAsync(filePath, "MZ"); + + _fakeSigningService.Subscriptions = + [ + new AzureSubscription("sub-123", "Test Subscription") + ]; + _fakeSigningService.SigningAccounts = + [ + new SigningAccount("myaccount", "myrg", "eastus", "https://eus.codesigning.azure.net") + ]; + _fakeSigningService.CertificateProfiles = []; + + var result = await ParseAndInvokeWithCaptureAsync(_command, [filePath]); + + Assert.AreEqual(1, result); + var allOutput = ConsoleStdOut.ToString() + ConsoleStdErr.ToString(); + StringAssert.Contains(allOutput, "No certificate profiles found"); + } + + [TestMethod] + public async Task AzSign_MetadataFileNotFound_ReturnsError() + { + var filePath = Path.Combine(_tempDirectory.FullName, "test.exe"); + await File.WriteAllTextAsync(filePath, "MZ"); + + var nonExistentMetadata = Path.Combine(_tempDirectory.FullName, "nonexistent.json"); + + var result = await ParseAndInvokeWithCaptureAsync(_command, ["--metadata-file", nonExistentMetadata, filePath]); + + Assert.AreEqual(1, result); + var allOutput = ConsoleStdOut.ToString() + ConsoleStdErr.ToString(); + StringAssert.Contains(allOutput, "Metadata file not found"); + } + + [TestMethod] + public async Task AzSign_SpecifiedAccountNotFound_ReturnsError() + { + var filePath = Path.Combine(_tempDirectory.FullName, "test.exe"); + await File.WriteAllTextAsync(filePath, "MZ"); + + _fakeSigningService.Subscriptions = + [ + new AzureSubscription("sub-123", "Test Subscription") + ]; + _fakeSigningService.SigningAccounts = []; + + var result = await ParseAndInvokeWithCaptureAsync(_command, + ["--subscription", "sub-123", "--resource-group", "myrg", "--account", "nonexistent", filePath]); + + Assert.AreEqual(1, result); + var allOutput = ConsoleStdOut.ToString() + ConsoleStdErr.ToString(); + StringAssert.Contains(allOutput, "not found"); + } + + [TestMethod] + public async Task AzSign_SingleSubscription_AutoSelects() + { + var filePath = Path.Combine(_tempDirectory.FullName, "test.exe"); + await File.WriteAllTextAsync(filePath, "MZ"); + + _fakeSigningService.Subscriptions = + [ + new AzureSubscription("sub-123", "Test Subscription") + ]; + _fakeSigningService.SigningAccounts = + [ + new SigningAccount("myaccount", "myrg", "eastus", "https://eus.codesigning.azure.net") + ]; + _fakeSigningService.CertificateProfiles = + [ + new CertificateProfile("myprofile", "PublicTrust", "Active") + ]; + + // Accept the confirmation prompt (single sub/account/profile are auto-selected first). + TestAnsiConsole.Input.PushKey(ConsoleKey.Enter); + + var result = await ParseAndInvokeWithCaptureAsync(_command, [filePath]); + + // Reaches the signing stage and succeeds via the faked sign-tool service. + Assert.AreEqual(0, result); + Assert.AreEqual(1, _fakeSignToolService.CallCount, "Should reach the signing stage"); + var allOutput = ConsoleStdOut.ToString() + ConsoleStdErr.ToString(); + Assert.IsFalse(allOutput.Contains("No Azure subscriptions found"), "Should not fail at subscription selection"); + Assert.IsFalse(allOutput.Contains("No Trusted Signing accounts found"), "Should not fail at account selection"); + Assert.IsFalse(allOutput.Contains("No certificate profiles found"), "Should not fail at profile selection"); + } + + [TestMethod] + public async Task AzSign_MultipleSubscriptions_WithFlag_SkipsPrompt() + { + var filePath = Path.Combine(_tempDirectory.FullName, "test.exe"); + await File.WriteAllTextAsync(filePath, "MZ"); + + _fakeSigningService.Subscriptions = + [ + new AzureSubscription("sub-123", "Sub 1"), + new AzureSubscription("sub-456", "Sub 2") + ]; + _fakeSigningService.SigningAccounts = + [ + new SigningAccount("myaccount", "myrg", "eastus", "https://eus.codesigning.azure.net") + ]; + _fakeSigningService.CertificateProfiles = + [ + new CertificateProfile("myprofile", "PublicTrust", "Active") + ]; + + // Account/profile are auto-selected (single), so the sign confirmation still prompts. + TestAnsiConsole.Input.PushKey(ConsoleKey.Enter); + + // Using --subscription flag should skip the subscription prompt + var result = await ParseAndInvokeWithCaptureAsync(_command, ["--subscription", "sub-123", filePath]); + + Assert.AreEqual(0, result); + var allOutput = ConsoleStdOut.ToString() + ConsoleStdErr.ToString(); + Assert.IsFalse(allOutput.Contains("Select an Azure subscription"), "Should not prompt for subscription when flag is provided"); + } + + [TestMethod] + public async Task AzSign_AllFlagsProvided_SkipsAllPrompting() + { + var filePath = Path.Combine(_tempDirectory.FullName, "test.exe"); + await File.WriteAllTextAsync(filePath, "MZ"); + + _fakeSigningService.Subscriptions = + [ + new AzureSubscription("sub-123", "Test Subscription") + ]; + _fakeSigningService.SigningAccounts = + [ + new SigningAccount("myaccount", "myrg", "eastus", "https://eus.codesigning.azure.net") + ]; + _fakeSigningService.CertificateProfiles = + [ + new CertificateProfile("myprofile", "PublicTrust", "Active") + ]; + + // No prompt input pushed: with account+profile supplied, no confirmation should be required. + var result = await ParseAndInvokeWithCaptureAsync(_command, + ["--subscription", "sub-123", "--resource-group", "myrg", "--account", "myaccount", "--profile", "myprofile", filePath]); + + Assert.AreEqual(0, result); + Assert.AreEqual(1, _fakeSignToolService.CallCount, "Should reach the signing stage without prompting"); + Assert.AreEqual("fake-tenant-id", _fakeSignToolService.LastTenantId, "Tenant ID should be forwarded to signtool"); + var allOutput = ConsoleStdOut.ToString() + ConsoleStdErr.ToString(); + Assert.IsFalse(allOutput.Contains("Select"), "Should not show any selection prompts when all flags are provided"); + Assert.IsFalse(allOutput.Contains("Sign with profile"), "Should not show a confirmation prompt when account and profile are explicit"); + } + + [TestMethod] + public async Task AzSign_AllFlagsProvided_GeneratesMetadataAndCleansUp() + { + var filePath = Path.Combine(_tempDirectory.FullName, "test.exe"); + await File.WriteAllTextAsync(filePath, "MZ"); + + _fakeSigningService.Subscriptions = [new AzureSubscription("sub-123", "Test Subscription")]; + _fakeSigningService.SigningAccounts = [new SigningAccount("myaccount", "myrg", "eastus", "https://eus.codesigning.azure.net")]; + _fakeSigningService.CertificateProfiles = [new CertificateProfile("myprofile", "PublicTrust", "Active")]; + + var result = await ParseAndInvokeWithCaptureAsync(_command, + ["--subscription", "sub-123", "--resource-group", "myrg", "--account", "myaccount", "--profile", "myprofile", filePath]); + + Assert.AreEqual(0, result); + + // The generated metadata file should contain the resolved signing identity... + Assert.IsNotNull(_fakeSignToolService.LastMetadataContent); + StringAssert.Contains(_fakeSignToolService.LastMetadataContent, "myaccount"); + StringAssert.Contains(_fakeSignToolService.LastMetadataContent, "myprofile"); + StringAssert.Contains(_fakeSignToolService.LastMetadataContent, "https://eus.codesigning.azure.net"); + + // ...and the temp file must be cleaned up afterward. + Assert.IsNotNull(_fakeSignToolService.LastMetadataFilePath); + Assert.IsFalse(File.Exists(_fakeSignToolService.LastMetadataFilePath!.FullName), "Generated metadata file should be deleted after signing"); + } + + [TestMethod] + public async Task AzSign_WithMetadataFile_UsesFileDirectly_DoesNotDelete() + { + var filePath = Path.Combine(_tempDirectory.FullName, "test.exe"); + await File.WriteAllTextAsync(filePath, "MZ"); + + var metadataPath = Path.Combine(_tempDirectory.FullName, "metadata.json"); + await File.WriteAllTextAsync(metadataPath, "{\"Endpoint\":\"https://eus.codesigning.azure.net\"}"); + + var result = await ParseAndInvokeWithCaptureAsync(_command, ["--metadata-file", metadataPath, filePath]); + + Assert.AreEqual(0, result); + Assert.AreEqual(1, _fakeSignToolService.CallCount); + Assert.AreEqual(metadataPath, _fakeSignToolService.LastMetadataFilePath!.FullName); + // A user-supplied metadata file must not be deleted. + Assert.IsTrue(File.Exists(metadataPath), "User-provided metadata file should not be deleted"); + } + + [TestMethod] + public async Task AzSign_SignToolFails_ReturnsError() + { + var filePath = Path.Combine(_tempDirectory.FullName, "test.exe"); + await File.WriteAllTextAsync(filePath, "MZ"); + + _fakeSigningService.Subscriptions = [new AzureSubscription("sub-123", "Test Subscription")]; + _fakeSigningService.SigningAccounts = [new SigningAccount("myaccount", "myrg", "eastus", "https://eus.codesigning.azure.net")]; + _fakeSigningService.CertificateProfiles = [new CertificateProfile("myprofile", "PublicTrust", "Active")]; + _fakeSignToolService.ShouldFail = true; + + var result = await ParseAndInvokeWithCaptureAsync(_command, + ["--subscription", "sub-123", "--resource-group", "myrg", "--account", "myaccount", "--profile", "myprofile", filePath]); + + Assert.AreEqual(1, result); + var allOutput = ConsoleStdOut.ToString() + ConsoleStdErr.ToString(); + StringAssert.Contains(allOutput, "signtool.exe execution failed"); + } +} + +internal class FakeAzureAuthService : IAzureAuthService +{ + public bool ShouldFail { get; set; } + public string FailureMessage { get; set; } = "Authentication failed"; + public bool IsInteractive { get; set; } = true; + public string? TenantId { get; set; } = "fake-tenant-id"; + + public Task GetAccessTokenAsync(string scope, CancellationToken cancellationToken = default) + { + if (ShouldFail) + { + throw new InvalidOperationException(FailureMessage); + } + return Task.FromResult("fake-access-token"); + } +} + +internal class FakeAzureSignToolService : IAzureSignToolService +{ + public bool ShouldFail { get; set; } + public int CallCount { get; private set; } + public FileInfo? LastFilePath { get; private set; } + public FileInfo? LastMetadataFilePath { get; private set; } + public string? LastMetadataContent { get; private set; } + public string? LastTenantId { get; private set; } + + public async Task SignAsync( + FileInfo filePath, + FileInfo metadataFilePath, + string? tenantId, + TaskContext taskContext, + CancellationToken cancellationToken = default) + { + CallCount++; + LastFilePath = filePath; + LastMetadataFilePath = metadataFilePath; + LastTenantId = tenantId; + + // Capture the metadata contents now, since the handler deletes generated files after we return. + metadataFilePath.Refresh(); + if (metadataFilePath.Exists) + { + LastMetadataContent = await File.ReadAllTextAsync(metadataFilePath.FullName, cancellationToken); + } + + if (ShouldFail) + { + throw new InvalidOperationException("signtool.exe execution failed with exit code 1."); + } + } +} + +internal class FakeAzureSigningService : IAzureSigningService +{ + public IReadOnlyList Subscriptions { get; set; } = + [ + new AzureSubscription("sub-123", "Test Subscription") + ]; + + public IReadOnlyList SigningAccounts { get; set; } = + [ + new SigningAccount("test-account", "test-rg", "eastus", "https://eus.codesigning.azure.net") + ]; + + public IReadOnlyList CertificateProfiles { get; set; } = + [ + new CertificateProfile("test-profile", "PublicTrust", "Active") + ]; + + public Task> ListSubscriptionsAsync(string accessToken, CancellationToken cancellationToken = default) + { + return Task.FromResult(Subscriptions); + } + + public Task> ListSigningAccountsAsync(string accessToken, string subscriptionId, string? resourceGroup = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(SigningAccounts); + } + + public Task> ListCertificateProfilesAsync(string accessToken, string subscriptionId, string resourceGroup, string accountName, CancellationToken cancellationToken = default) + { + return Task.FromResult(CertificateProfiles); + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/AzureAuthServiceTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/AzureAuthServiceTests.cs new file mode 100644 index 00000000..15cdae62 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/AzureAuthServiceTests.cs @@ -0,0 +1,175 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using Azure.Core; +using Azure.Identity; +using Microsoft.Extensions.Logging.Abstractions; +using Spectre.Console.Testing; +using WinApp.Cli.Services; + +namespace WinApp.Cli.Tests; + +[TestClass] +public class AzureAuthServiceTests +{ + [TestMethod] + [DataRow("72f988bf-86f1-41af-91ab-2d7cd011db47")] // GUID tenant + [DataRow("contoso.onmicrosoft.com")] // domain tenant + [DataRow("my-tenant.example.co.uk")] + public void IsValidTenantId_AcceptsGuidsAndDomains(string tenantId) + { + Assert.IsTrue(AzureAuthService.IsValidTenantId(tenantId)); + } + + [TestMethod] + [DataRow("")] + [DataRow(" ")] + [DataRow("not a tenant")] // whitespace + [DataRow("tenant && rm -rf /")] // shell metacharacters + [DataRow("--query \"[].id\"")] // extra CLI argument injection + [DataRow("tenant\"; calc.exe; \"")] + [DataRow("tenant`whoami`")] + [DataRow("contoso.onmicrosoft.com --debug")] + public void IsValidTenantId_RejectsInvalidOrUnsafeInput(string tenantId) + { + Assert.IsFalse(AzureAuthService.IsValidTenantId(tenantId)); + } + + private const string ArmScope = "https://management.azure.com/.default"; + + [TestMethod] + public async Task GetAccessTokenAsync_WhenPrimaryCredentialSucceeds_ReturnsToken() + { + var service = new TestableAzureAuthService + { + PrimaryCredential = new StubCredential("primary-token"), + }; + + var token = await service.GetAccessTokenAsync(ArmScope); + + Assert.AreEqual("primary-token", token); + Assert.AreEqual(0, service.RunAzLoginCallCount, "Should not fall back to az login when the primary credential works"); + } + + [TestMethod] + public async Task GetAccessTokenAsync_NonInteractiveAndCredentialFails_ThrowsCiGuidance() + { + var service = new TestableAzureAuthService + { + InteractiveOverride = false, + PrimaryCredential = new ThrowingCredential(), + }; + + var ex = await Assert.ThrowsExactlyAsync( + () => service.GetAccessTokenAsync(ArmScope)); + + StringAssert.Contains(ex.Message, "AZURE_CLIENT_SECRET"); + Assert.AreEqual(0, service.RunAzLoginCallCount, "Non-interactive sessions must never launch az login"); + } + + [TestMethod] + public async Task GetAccessTokenAsync_InteractiveButNoAzureCli_ThrowsInstallGuidance() + { + var service = new TestableAzureAuthService + { + InteractiveOverride = true, + PrimaryCredential = new ThrowingCredential(), + AzCliPath = null, + }; + + var ex = await Assert.ThrowsExactlyAsync( + () => service.GetAccessTokenAsync(ArmScope)); + + StringAssert.Contains(ex.Message, "Install the Azure CLI"); + } + + [TestMethod] + public async Task GetAccessTokenAsync_InteractiveLoginSucceeds_RetriesWithCliCredential() + { + var service = new TestableAzureAuthService + { + InteractiveOverride = true, + PrimaryCredential = new ThrowingCredential(), + AzCliPath = @"C:\fake\az.cmd", + LoginResult = true, + CliCredential = new StubCredential("cli-token"), + SeedTenantId = "72f988bf-86f1-41af-91ab-2d7cd011db47", + }; + + var token = await service.GetAccessTokenAsync(ArmScope); + + Assert.AreEqual("cli-token", token); + Assert.AreEqual(1, service.RunAzLoginCallCount); + Assert.AreEqual("72f988bf-86f1-41af-91ab-2d7cd011db47", service.LastLoginTenantId); + } + + [TestMethod] + public async Task GetAccessTokenAsync_InteractiveLoginFails_Throws() + { + var service = new TestableAzureAuthService + { + InteractiveOverride = true, + PrimaryCredential = new ThrowingCredential(), + AzCliPath = @"C:\fake\az.cmd", + LoginResult = false, + SeedTenantId = "72f988bf-86f1-41af-91ab-2d7cd011db47", + }; + + var ex = await Assert.ThrowsExactlyAsync( + () => service.GetAccessTokenAsync(ArmScope)); + + StringAssert.Contains(ex.Message, "Azure CLI login failed"); + } + + private sealed class TestableAzureAuthService : AzureAuthService + { + public TestableAzureAuthService() + : base(NullLogger.Instance, new TestConsole()) + { + } + + public bool? InteractiveOverride { get; init; } + public TokenCredential PrimaryCredential { get; init; } = new ThrowingCredential(); + public TokenCredential CliCredential { get; init; } = new StubCredential("cli-token"); + public string? AzCliPath { get; init; } + public bool LoginResult { get; init; } + public int RunAzLoginCallCount { get; private set; } + public string? LastLoginTenantId { get; private set; } + + // Pre-seed the tenant so the interactive paths don't block on a prompt. + public string? SeedTenantId { init => TenantId = value; } + + public override bool IsInteractive => InteractiveOverride ?? base.IsInteractive; + + protected override TokenCredential CreateCredential() => PrimaryCredential; + + protected override TokenCredential CreateAzureCliCredential() => CliCredential; + + protected override string? FindAzureCli() => AzCliPath; + + protected override Task RunAzLoginAsync(string azPath, string tenantId, CancellationToken cancellationToken) + { + RunAzLoginCallCount++; + LastLoginTenantId = tenantId; + return Task.FromResult(LoginResult); + } + } + + private sealed class StubCredential(string token) : TokenCredential + { + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + => new(token, DateTimeOffset.UtcNow.AddHours(1)); + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + => new(GetToken(requestContext, cancellationToken)); + } + + private sealed class ThrowingCredential : TokenCredential + { + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + => throw new AuthenticationFailedException("no credentials"); + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + => throw new AuthenticationFailedException("no credentials"); + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/AzureSigningServiceTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/AzureSigningServiceTests.cs new file mode 100644 index 00000000..012e2ec4 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/AzureSigningServiceTests.cs @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.Net; +using Microsoft.Extensions.Logging.Abstractions; +using WinApp.Cli.Services; + +namespace WinApp.Cli.Tests; + +[TestClass] +public class AzureSigningServiceTests +{ + private static AzureSigningService CreateService(StubHttpMessageHandler handler) + { + var http = new HttpClient(handler); + return new AzureSigningService(NullLogger.Instance, http); + } + + [TestMethod] + public async Task ListSubscriptionsAsync_ParsesAndFiltersByEnabledState() + { + const string json = """ + { + "value": [ + { "subscriptionId": "sub-1", "displayName": "Enabled Sub", "state": "Enabled" }, + { "subscriptionId": "sub-2", "displayName": "Disabled Sub", "state": "Disabled" } + ] + } + """; + var handler = new StubHttpMessageHandler(HttpStatusCode.OK, json); + + var service = CreateService(handler); + var subscriptions = await service.ListSubscriptionsAsync("token"); + + // Only the enabled subscription is returned. + Assert.AreEqual(1, subscriptions.Count); + Assert.AreEqual("sub-1", subscriptions[0].SubscriptionId); + Assert.AreEqual("Enabled Sub", subscriptions[0].DisplayName); + + // The bearer token is attached and the subscriptions endpoint is targeted. + Assert.AreEqual("Bearer", handler.LastRequest!.Headers.Authorization!.Scheme); + Assert.AreEqual("token", handler.LastRequest.Headers.Authorization.Parameter); + StringAssert.Contains(handler.LastRequestUri!, "https://management.azure.com/subscriptions?api-version="); + } + + [TestMethod] + public async Task ListSigningAccountsAsync_WithResourceGroup_BuildsScopedUrlAndParsesAccount() + { + const string json = """ + { + "value": [ + { + "name": "myaccount", + "location": "eastus", + "id": "/subscriptions/sub-1/resourceGroups/my-rg/providers/Microsoft.CodeSigning/codeSigningAccounts/myaccount", + "properties": { "accountUri": "https://eus.codesigning.azure.net" } + } + ] + } + """; + var handler = new StubHttpMessageHandler(HttpStatusCode.OK, json); + + var service = CreateService(handler); + var accounts = await service.ListSigningAccountsAsync("token", "sub-1", "my-rg"); + + Assert.AreEqual(1, accounts.Count); + Assert.AreEqual("myaccount", accounts[0].Name); + Assert.AreEqual("my-rg", accounts[0].ResourceGroup); + Assert.AreEqual("eastus", accounts[0].Location); + Assert.AreEqual("https://eus.codesigning.azure.net", accounts[0].AccountUri); + + // Resource-group-scoped URL is used when a group is supplied. + StringAssert.Contains(handler.LastRequestUri!, "/resourceGroups/my-rg/providers/Microsoft.CodeSigning/codeSigningAccounts"); + } + + [TestMethod] + public async Task ListSigningAccountsAsync_WithoutResourceGroup_BuildsSubscriptionWideUrl() + { + var handler = new StubHttpMessageHandler(HttpStatusCode.OK, """{ "value": [] }"""); + + var service = CreateService(handler); + var accounts = await service.ListSigningAccountsAsync("token", "sub-1"); + + Assert.AreEqual(0, accounts.Count); + StringAssert.Contains(handler.LastRequestUri!, "/subscriptions/sub-1/providers/Microsoft.CodeSigning/codeSigningAccounts"); + Assert.IsFalse(handler.LastRequestUri!.Contains("/resourceGroups/"), "Should not include a resource group segment"); + } + + [TestMethod] + public async Task ListCertificateProfilesAsync_ParsesProfiles() + { + const string json = """ + { + "value": [ + { "name": "profile-a", "properties": { "profileType": "PublicTrust", "status": "Active" } } + ] + } + """; + var handler = new StubHttpMessageHandler(HttpStatusCode.OK, json); + + var service = CreateService(handler); + var profiles = await service.ListCertificateProfilesAsync("token", "sub-1", "my-rg", "myaccount"); + + Assert.AreEqual(1, profiles.Count); + Assert.AreEqual("profile-a", profiles[0].Name); + Assert.AreEqual("PublicTrust", profiles[0].ProfileType); + Assert.AreEqual("Active", profiles[0].Status); + StringAssert.Contains(handler.LastRequestUri!, "/codeSigningAccounts/myaccount/certificateProfiles"); + } + + [TestMethod] + public async Task GetArmResponse_OnErrorStatus_SurfacesParsedAzureError() + { + const string errorJson = """ + { "error": { "code": "AuthorizationFailed", "message": "The client does not have authorization." } } + """; + var handler = new StubHttpMessageHandler(HttpStatusCode.Forbidden, errorJson); + + var service = CreateService(handler); + var ex = await Assert.ThrowsExactlyAsync( + () => service.ListSubscriptionsAsync("token")); + + StringAssert.Contains(ex.Message, "AuthorizationFailed"); + StringAssert.Contains(ex.Message, "The client does not have authorization."); + } + + [TestMethod] + public async Task GetArmResponse_OnErrorStatusWithNonJsonBody_FallsBackToStatusCode() + { + var handler = new StubHttpMessageHandler(HttpStatusCode.InternalServerError, "boom"); + + var service = CreateService(handler); + var ex = await Assert.ThrowsExactlyAsync( + () => service.ListSubscriptionsAsync("token")); + + StringAssert.Contains(ex.Message, "500"); + } + + private sealed class StubHttpMessageHandler(HttpStatusCode statusCode, string body) : HttpMessageHandler + { + public HttpRequestMessage? LastRequest { get; private set; } + public string? LastRequestUri { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + LastRequest = request; + LastRequestUri = request.RequestUri?.ToString(); + return Task.FromResult(new HttpResponseMessage(statusCode) + { + Content = new StringContent(body) + }); + } + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/BuildToolsServiceTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/BuildToolsServiceTests.cs index 52333f9f..4c0a72ca 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/BuildToolsServiceTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/BuildToolsServiceTests.cs @@ -163,7 +163,7 @@ public async Task RunBuildToolAsync_WithValidTool_ReturnsOutput() File.WriteAllText(fakeToolPath, "@echo Hello from fake tool"); // Act - var (stdout, stderr) = await _buildToolsService.RunBuildToolAsync(new GenericTool("echo.cmd"), "", TestTaskContext, true, TestContext.CancellationToken); + var (stdout, stderr) = await _buildToolsService.RunBuildToolAsync(new GenericTool("echo.cmd"), "", TestTaskContext, true, cancellationToken: TestContext.CancellationToken); // Assert Assert.Contains("Hello from fake tool", stdout); @@ -179,7 +179,7 @@ public async Task RunBuildToolAsync_WithNonExistentTool_ThrowsFileNotFoundExcept // Act & Assert await Assert.ThrowsExactlyAsync(async () => { - await _buildToolsService.RunBuildToolAsync(new GenericTool("nonexistent.exe"), "", TestTaskContext, true, TestContext.CancellationToken); + await _buildToolsService.RunBuildToolAsync(new GenericTool("nonexistent.exe"), "", TestTaskContext, true, cancellationToken: TestContext.CancellationToken); }); } @@ -326,7 +326,7 @@ public async Task RunBuildToolAsync_WithNoExistingPackage_AutoInstallsAndRuns() { // Create a simple batch command that outputs something // This will either succeed (if BuildTools installs successfully) or throw an exception - await _buildToolsService.RunBuildToolAsync(new GenericTool("echo.cmd"), "test", TestTaskContext, true, TestContext.CancellationToken); + await _buildToolsService.RunBuildToolAsync(new GenericTool("echo.cmd"), "test", TestTaskContext, true, cancellationToken: TestContext.CancellationToken); // If we reach here, the auto-installation worked - test passes } @@ -353,7 +353,7 @@ public async Task RunBuildToolAsync_WithExistingTool_RunsDirectly() File.WriteAllText(batchFile, "@echo Hello from test tool"); // Act - var (stdout, stderr) = await _buildToolsService.RunBuildToolAsync(new GenericTool("test.cmd"), "", TestTaskContext, true, TestContext.CancellationToken); + var (stdout, stderr) = await _buildToolsService.RunBuildToolAsync(new GenericTool("test.cmd"), "", TestTaskContext, true, cancellationToken: TestContext.CancellationToken); // Assert Assert.Contains("Hello from test tool", stdout); @@ -383,7 +383,7 @@ public async Task RunBuildToolAsync_WithPrintErrorsTrue_WritesErrorOutput() // Act: Run with printErrors=true - error SHOULD be printed via PrintErrorText var exception = await Assert.ThrowsExactlyAsync(async () => { - await _buildToolsService.RunBuildToolAsync(new GenericTool("failing.cmd"), "", TestTaskContext, printErrors: true, TestContext.CancellationToken); + await _buildToolsService.RunBuildToolAsync(new GenericTool("failing.cmd"), "", TestTaskContext, printErrors: true, cancellationToken: TestContext.CancellationToken); }); // Verify the exception captures the error info @@ -410,7 +410,7 @@ public async Task RunBuildToolAsync_WithPrintErrorsFalse_DoesNotWriteErrorOutput // Act: Run with printErrors=false - error should NOT be printed via PrintErrorText var exception = await Assert.ThrowsExactlyAsync(async () => { - await _buildToolsService.RunBuildToolAsync(new GenericTool("failing.cmd"), "", TestTaskContext, printErrors: false, TestContext.CancellationToken); + await _buildToolsService.RunBuildToolAsync(new GenericTool("failing.cmd"), "", TestTaskContext, printErrors: false, cancellationToken: TestContext.CancellationToken); }); // Verify the exception still captures the error info diff --git a/src/winapp-CLI/WinApp.Cli.Tests/BundleServiceTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/BundleServiceTests.cs index 91853b5c..59ed47da 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/BundleServiceTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/BundleServiceTests.cs @@ -108,7 +108,7 @@ public Task EnsureBuildToolAvailableAsync(string toolName, TaskContext public Task EnsureBuildToolsAsync(TaskContext taskContext, bool forceLatest = false, CancellationToken cancellationToken = default) => Task.FromResult(null); - public Task<(string stdout, string stderr)> RunBuildToolAsync(Tool tool, string arguments, TaskContext taskContext, bool printErrors = true, CancellationToken cancellationToken = default) + public Task<(string stdout, string stderr)> RunBuildToolAsync(Tool tool, string arguments, TaskContext taskContext, bool printErrors = true, FileInfo? toolPathOverride = null, IReadOnlyDictionary? environment = null, CancellationToken cancellationToken = default) { Invocations.Add((tool.ExecutableName, arguments)); return Task.FromResult(("", "")); diff --git a/src/winapp-CLI/WinApp.Cli.Tests/MsixServiceBundleOrchestrationTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/MsixServiceBundleOrchestrationTests.cs index e3e4f4ae..a78f574c 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/MsixServiceBundleOrchestrationTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/MsixServiceBundleOrchestrationTests.cs @@ -72,7 +72,7 @@ public Task EnsureBuildToolAvailableAsync(string toolName, TaskContext return Task.FromResult(null); } - public Task<(string stdout, string stderr)> RunBuildToolAsync(Tool tool, string arguments, TaskContext taskContext, bool printErrors = true, CancellationToken cancellationToken = default) + public Task<(string stdout, string stderr)> RunBuildToolAsync(Tool tool, string arguments, TaskContext taskContext, bool printErrors = true, FileInfo? toolPathOverride = null, IReadOnlyDictionary? environment = null, CancellationToken cancellationToken = default) { Invocations.Add((tool.ExecutableName, arguments)); diff --git a/src/winapp-CLI/WinApp.Cli/Commands/AzSignCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/AzSignCommand.cs new file mode 100644 index 00000000..42f55ecc --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Commands/AzSignCommand.cs @@ -0,0 +1,411 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.CommandLine; +using System.CommandLine.Invocation; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Spectre.Console; +using WinApp.Cli.Services; + +namespace WinApp.Cli.Commands; + +internal class AzSignCommand : Command, IShortDescription +{ + public string ShortDescription => "Code-sign a file using Azure Trusted Signing"; + + public static Argument FilePathArgument { get; } + public static Option SubscriptionOption { get; } + public static Option ResourceGroupOption { get; } + public static Option AccountOption { get; } + public static Option ProfileOption { get; } + public static Option MetadataFileOption { get; } + + static AzSignCommand() + { + FilePathArgument = new Argument("file-path") + { + Description = "Path to the file to sign (exe, msix, or msixbundle)" + }; + FilePathArgument.AcceptExistingOnly(); + + SubscriptionOption = new Option("--subscription", "-s") + { + Description = "Azure subscription ID to use. If not provided and multiple subscriptions exist, you will be prompted." + }; + + ResourceGroupOption = new Option("--resource-group", "-r") + { + Description = "Resource group to narrow down signing accounts" + }; + + AccountOption = new Option("--account") + { + Description = "Signing account name. Must be used with --resource-group" + }; + + ProfileOption = new Option("--profile", "-p") + { + Description = "Certificate profile name. Must be used with --account" + }; + + MetadataFileOption = new Option("--metadata-file", "-m") + { + Description = "Path to an existing metadata.json file. Skips all prompting and uses this file directly for signing." + }; + } + + public AzSignCommand() : base("az-sign", "Code-sign a file using Azure Trusted Signing. Signs executables, MSIX packages, or MSIX bundles using a cloud-managed signing identity. Example: winapp az-sign ./app.msix") + { + Arguments.Add(FilePathArgument); + Options.Add(SubscriptionOption); + Options.Add(ResourceGroupOption); + Options.Add(AccountOption); + Options.Add(ProfileOption); + Options.Add(MetadataFileOption); + } + + public class Handler( + IAzureAuthService azureAuthService, + IAzureSigningService azureSigningService, + IAzureSignToolService azureSignToolService, + IStatusService statusService, + IAnsiConsole ansiConsole, + ILogger logger) : AsynchronousCommandLineAction + { + private const string ArmScope = "https://management.azure.com/.default"; + + public override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) + { + var filePath = parseResult.GetRequiredValue(FilePathArgument); + var subscription = parseResult.GetValue(SubscriptionOption); + var resourceGroup = parseResult.GetValue(ResourceGroupOption); + var account = parseResult.GetValue(AccountOption); + var profile = parseResult.GetValue(ProfileOption); + var metadataFile = parseResult.GetValue(MetadataFileOption); + + // Validate flag dependencies + if (account != null && resourceGroup == null) + { + logger.LogError("--account must be used with --resource-group"); + return 1; + } + + if (profile != null && account == null) + { + logger.LogError("--profile must be used with --account"); + return 1; + } + + // Validate the metadata file exists before doing any Azure work, so an obvious + // input error fails fast rather than after an interactive login. + if (metadataFile != null && !metadataFile.Exists) + { + logger.LogError("Metadata file not found: {Path}", metadataFile.FullName); + return 1; + } + + try + { + // Determine what to sign with + FileInfo metadataFilePath; + bool generatedMetadata; + + if (metadataFile != null) + { + metadataFilePath = metadataFile; + generatedMetadata = false; + } + else + { + // Authenticate for ARM resource discovery (not needed when metadata file is provided, + // since the dlib authenticates independently for the signing data plane) + var accessToken = await azureAuthService.GetAccessTokenAsync(ArmScope, cancellationToken); + + if (string.IsNullOrEmpty(accessToken)) + { + logger.LogError("Failed to authenticate with Azure."); + return 1; + } + + // Discover resources (may involve REST calls shown in status) + var metadata = await DiscoverAndSelectResourcesAsync( + accessToken, subscription, resourceGroup, account, profile, cancellationToken); + + if (metadata == null) + { + return 1; + } + + // Confirm selection (outside status context) unless the signing identity + // was fully specified on the command line, or we're running non-interactively + // (where a prompt would only hang/fail automated callers). + var fullySpecified = account != null && profile != null; + if (!fullySpecified && azureAuthService.IsInteractive) + { + var confirm = await ansiConsole.PromptAsync( + new ConfirmationPrompt( + $"Sign with profile [green]{metadata.Value.ProfileName}[/] in account [green]{metadata.Value.AccountName}[/]?") + { + DefaultValue = true + }, + cancellationToken); + + if (!confirm) + { + return 1; + } + } + + // Generate metadata.json + metadataFilePath = await GenerateMetadataFileAsync(metadata.Value, cancellationToken); + generatedMetadata = true; + } + + // Step 5: Sign with signtool (inside status context) + try + { + return await statusService.ExecuteWithStatusAsync($"Signing: {filePath.Name}", async (taskContext, ct) => + { + await azureSignToolService.SignAsync(filePath, metadataFilePath, azureAuthService.TenantId, taskContext, ct); + return (0, $"Successfully signed: {filePath.Name}"); + }, cancellationToken); + } + finally + { + if (generatedMetadata) + { + CleanupMetadataFile(metadataFilePath); + } + } + } + catch (InvalidOperationException ex) + { + logger.LogError("{Message}", ex.Message); + return 1; + } + catch (OperationCanceledException) + { + logger.LogError("Operation cancelled."); + return 1; + } + } + + private async Task DiscoverAndSelectResourcesAsync( + string accessToken, string? subscriptionId, string? resourceGroup, + string? accountName, string? profileName, + CancellationToken cancellationToken) + { + // Resolve subscription + if (string.IsNullOrEmpty(subscriptionId)) + { + subscriptionId = await SelectSubscriptionAsync(accessToken, cancellationToken); + if (subscriptionId == null) + { + return null; + } + } + + // Resolve signing account + string resolvedResourceGroup; + string resolvedAccountName; + string? accountUri; + + if (!string.IsNullOrEmpty(accountName)) + { + resolvedResourceGroup = resourceGroup!; + resolvedAccountName = accountName; + // We need the account URI — fetch it + var accounts = await azureSigningService.ListSigningAccountsAsync( + accessToken, subscriptionId, resolvedResourceGroup, cancellationToken); + var matchedAccount = accounts.FirstOrDefault(a => + string.Equals(a.Name, accountName, StringComparison.OrdinalIgnoreCase)); + accountUri = matchedAccount?.AccountUri; + + if (matchedAccount == null) + { + throw new InvalidOperationException( + $"Signing account '{accountName}' not found in resource group '{resolvedResourceGroup}'."); + } + } + else + { + var selectedAccount = await SelectSigningAccountAsync( + accessToken, subscriptionId, resourceGroup, cancellationToken); + if (selectedAccount == null) + { + return null; + } + resolvedResourceGroup = selectedAccount.ResourceGroup; + resolvedAccountName = selectedAccount.Name; + accountUri = selectedAccount.AccountUri; + } + + // Resolve certificate profile + string resolvedProfileName; + if (!string.IsNullOrEmpty(profileName)) + { + resolvedProfileName = profileName; + } + else + { + var selectedProfile = await SelectCertificateProfileAsync( + accessToken, subscriptionId, resolvedResourceGroup, resolvedAccountName, + cancellationToken); + if (selectedProfile == null) + { + return null; + } + resolvedProfileName = selectedProfile; + } + + // The endpoint must come from the account's accountUri property (regional, e.g. https://eus.codesigning.azure.net) + if (string.IsNullOrEmpty(accountUri)) + { + throw new InvalidOperationException( + $"Signing account '{resolvedAccountName}' does not have an endpoint URI. " + + "The account may still be provisioning. Please try again in a few minutes, " + + "or specify a metadata file with --metadata-file."); + } + + return new SigningMetadata(accountUri, resolvedAccountName, resolvedProfileName); + } + + private async Task SelectSubscriptionAsync( + string accessToken, CancellationToken cancellationToken) + { + var subscriptions = await azureSigningService.ListSubscriptionsAsync(accessToken, cancellationToken); + + if (subscriptions.Count == 0) + { + throw new InvalidOperationException( + "No Azure subscriptions found. Ensure your account has access to at least one subscription."); + } + + if (subscriptions.Count == 1) + { + return subscriptions[0].SubscriptionId; + } + + // Prompt user to select + var choices = subscriptions.Select(s => $"{s.DisplayName} ({s.SubscriptionId})").ToList(); + var prompt = new SelectionPrompt() + .Title("Select an Azure subscription:") + .AddChoices(choices); + + var selected = await ansiConsole.PromptAsync(prompt, cancellationToken); + var index = choices.IndexOf(selected); + return subscriptions[index].SubscriptionId; + } + + private async Task SelectSigningAccountAsync( + string accessToken, string subscriptionId, string? resourceGroup, + CancellationToken cancellationToken) + { + var accounts = await azureSigningService.ListSigningAccountsAsync( + accessToken, subscriptionId, resourceGroup, cancellationToken); + + if (accounts.Count == 0) + { + var context = string.IsNullOrEmpty(resourceGroup) + ? "subscription" + : $"resource group '{resourceGroup}'"; + throw new InvalidOperationException( + $"No Trusted Signing accounts found in the {context}.\n" + + "Create one in the Azure portal or via 'az trustedsigning create'."); + } + + if (accounts.Count == 1) + { + return accounts[0]; + } + + // Format choices based on whether resource group was provided + List choices; + if (string.IsNullOrEmpty(resourceGroup)) + { + choices = accounts.Select(a => $"{a.Name}, Resource Group: {a.ResourceGroup}").ToList(); + } + else + { + choices = accounts.Select(a => a.Name).ToList(); + } + + var prompt = new SelectionPrompt() + .Title("Select a signing account:") + .AddChoices(choices); + + var selected = await ansiConsole.PromptAsync(prompt, cancellationToken); + var index = choices.IndexOf(selected); + return accounts[index]; + } + + private async Task SelectCertificateProfileAsync( + string accessToken, string subscriptionId, string resourceGroup, string accountName, + CancellationToken cancellationToken) + { + var profiles = await azureSigningService.ListCertificateProfilesAsync( + accessToken, subscriptionId, resourceGroup, accountName, cancellationToken); + + if (profiles.Count == 0) + { + throw new InvalidOperationException( + $"No certificate profiles found for signing account '{accountName}'.\n" + + "Create a certificate profile in the Azure portal after completing identity validation."); + } + + if (profiles.Count == 1) + { + return profiles[0].Name; + } + + var choices = profiles.Select(p => $"{p.Name} ({p.ProfileType})").ToList(); + var prompt = new SelectionPrompt() + .Title("Select a certificate profile:") + .AddChoices(choices); + + var selected = await ansiConsole.PromptAsync(prompt, cancellationToken); + // Strip the profile type suffix to get just the name + return selected[..selected.LastIndexOf(" (")]; + } + + private static async Task GenerateMetadataFileAsync(SigningMetadata metadata, CancellationToken cancellationToken) + { + var tempPath = Path.Combine(Path.GetTempPath(), $"winapp-az-sign-{Guid.NewGuid():N}.json"); + + using var stream = File.Create(tempPath); + using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true }); + writer.WriteStartObject(); + writer.WriteString("Endpoint", metadata.Endpoint); + writer.WriteString("CodeSigningAccountName", metadata.AccountName); + writer.WriteString("CertificateProfileName", metadata.ProfileName); + // Exclude SharedTokenCacheCredential — it picks up stale consumer tokens from the MSAL + // shared cache and fails because the Azure.CodeSigning app is AAD-only + writer.WriteStartArray("ExcludeCredentials"); + writer.WriteStringValue("SharedTokenCacheCredential"); + writer.WriteEndArray(); + writer.WriteEndObject(); + await writer.FlushAsync(cancellationToken); + + return new FileInfo(tempPath); + } + + private static void CleanupMetadataFile(FileInfo metadataFile) + { + try + { + metadataFile.Refresh(); + if (metadataFile.Exists) + { + metadataFile.Delete(); + } + } + catch + { + // Best effort cleanup + } + } + + private readonly record struct SigningMetadata(string Endpoint, string AccountName, string ProfileName); + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Commands/WinAppRootCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/WinAppRootCommand.cs index 766e777e..98082d20 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/WinAppRootCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/WinAppRootCommand.cs @@ -66,6 +66,7 @@ public WinAppRootCommand( GetWinappPathCommand getWinappPathCommand, CertCommand certCommand, SignCommand signCommand, + AzSignCommand azSignCommand, ToolCommand toolCommand, MSStoreCommand msStoreCommand, IAnsiConsole ansiConsole, @@ -84,6 +85,7 @@ public WinAppRootCommand( Subcommands.Add(getWinappPathCommand); Subcommands.Add(certCommand); Subcommands.Add(signCommand); + Subcommands.Add(azSignCommand); Subcommands.Add(toolCommand); Subcommands.Add(msStoreCommand); Subcommands.Add(createExternalCatalogCommand); @@ -100,7 +102,7 @@ public WinAppRootCommand( var helpOption = Options.OfType().First(); helpOption.Action = new CustomHelpAction(this, ansiConsole, ("Setup", [typeof(InitCommand), typeof(RestoreCommand), typeof(UpdateCommand)]), - ("Packaging & Signing", [typeof(PackageCommand), typeof(SignCommand), typeof(CertCommand), typeof(ManifestCommand), typeof(CreateExternalCatalogCommand)]), + ("Packaging & Signing", [typeof(PackageCommand), typeof(SignCommand), typeof(AzSignCommand), typeof(CertCommand), typeof(ManifestCommand), typeof(CreateExternalCatalogCommand)]), ("Development Tools", [typeof(CreateDebugIdentityCommand), typeof(MSStoreCommand), typeof(ToolCommand), typeof(GetWinappPathCommand), typeof(RunCommand), typeof(UnregisterCommand)]), ("UI Automation", [typeof(UiCommand)]) ); diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs b/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs index a55b1f2e..9ddcd9db 100644 --- a/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs +++ b/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs @@ -50,6 +50,10 @@ public static IServiceCollection ConfigureServices(this IServiceCollection servi .AddSingleton() .AddSingleton() .AddSingleton() + // Azure Trusted Signing services + .AddSingleton() + .AddSingleton() + .AddSingleton() // UI Automation services .AddSingleton() .AddSingleton() @@ -80,6 +84,7 @@ public static IServiceCollection ConfigureCommands(this IServiceCollection servi .UseCommandHandler() .UseCommandHandler() .UseCommandHandler() + .UseCommandHandler() .UseCommandHandler() .UseCommandHandler(false) .UseCommandHandler() diff --git a/src/winapp-CLI/WinApp.Cli/Services/AzureAuthService.cs b/src/winapp-CLI/WinApp.Cli/Services/AzureAuthService.cs new file mode 100644 index 00000000..dee6840e --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/AzureAuthService.cs @@ -0,0 +1,273 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.Diagnostics; +using System.Text.RegularExpressions; +using Azure.Core; +using Azure.Identity; +using Microsoft.Extensions.Logging; +using Spectre.Console; + +namespace WinApp.Cli.Services; + +/// +/// Provides Azure authentication using Azure.Identity's credential chain. +/// In interactive environments, falls back to running 'az login' when +/// DefaultAzureCredential fails — the Trusted Signing dlib requires +/// AzureCliCredential for local interactive signing. +/// +internal partial class AzureAuthService(ILogger logger, IAnsiConsole ansiConsole) : IAzureAuthService +{ + public virtual bool IsInteractive => + Environment.UserInteractive + && !Console.IsInputRedirected + && Environment.GetEnvironmentVariable("CI") == null + && Environment.GetEnvironmentVariable("TF_BUILD") == null + && Environment.GetEnvironmentVariable("GITHUB_ACTIONS") == null; + + public string? TenantId { get; protected set; } = Environment.GetEnvironmentVariable("AZURE_TENANT_ID"); + + public async Task GetAccessTokenAsync(string scope, CancellationToken cancellationToken = default) + { + var credential = CreateCredential(); + + try + { + var context = new TokenRequestContext([scope]); + var token = await credential.GetTokenAsync(context, cancellationToken); + LogAuthMethod(token); + return token.Token; + } + catch (AuthenticationFailedException) + { + if (!IsInteractive) + { + throw new InvalidOperationException( + "Azure authentication failed. No credentials found in the environment.\n\n" + + "For CI/CD, set these environment variables:\n" + + " AZURE_TENANT_ID - Your Azure AD tenant ID\n" + + " AZURE_CLIENT_ID - Service principal application ID\n" + + " AZURE_CLIENT_SECRET - Service principal secret\n\n" + + "For GitHub Actions OIDC, set:\n" + + " AZURE_TENANT_ID, AZURE_CLIENT_ID, and AZURE_FEDERATED_TOKEN_FILE\n\n" + + "For managed identity (Azure-hosted runners), no configuration is needed.\n\n" + + "Alternatively, ensure the Azure CLI is installed and run 'az login' before this command."); + } + + // The Trusted Signing dlib requires AzureCliCredential for interactive signing. + // Check if Azure CLI is available and run 'az login' for the user. + var azPath = FindAzureCli(); + if (azPath == null) + { + throw new InvalidOperationException( + "Azure authentication failed. To sign interactively, you have two options:\n\n" + + "Option 1: Install the Azure CLI and run this command again (login will be handled automatically)\n" + + " Install from: https://aka.ms/installazurecli\n\n" + + "Option 2: Set environment variables for a service principal:\n" + + " AZURE_TENANT_ID - Your Azure AD tenant ID\n" + + " AZURE_CLIENT_ID - Service principal application ID\n" + + " AZURE_CLIENT_SECRET - Service principal secret"); + } + + var tenantId = TenantId; + + if (string.IsNullOrEmpty(tenantId)) + { + tenantId = await ansiConsole.PromptAsync( + new TextPrompt("Enter your [green]Azure Tenant ID[/] (found in Azure Portal > Azure AD > Overview):") + .ValidationErrorMessage("[red]Enter a valid tenant ID — a GUID or a domain like contoso.onmicrosoft.com[/]") + .Validate(IsValidTenantId), + cancellationToken); + TenantId = tenantId; + } + else if (!IsValidTenantId(tenantId)) + { + throw new InvalidOperationException( + $"Invalid AZURE_TENANT_ID value '{tenantId}'. " + + "It must be a tenant GUID or a domain such as contoso.onmicrosoft.com."); + } + + logger.LogInformation("Signing in via Azure CLI..."); + var success = await RunAzLoginAsync(azPath, tenantId, cancellationToken); + + if (!success) + { + throw new InvalidOperationException("Azure CLI login failed. Please try running 'az login' manually."); + } + + // Retry with the now-valid Azure CLI credential + var retryCredential = CreateAzureCliCredential(); + var retryToken = await retryCredential.GetTokenAsync(new TokenRequestContext([scope]), cancellationToken); + logger.LogInformation("Authenticated via Azure CLI"); + return retryToken.Token; + } + } + + /// Creates the primary credential chain. Virtual to allow tests to substitute a fake. + protected virtual TokenCredential CreateCredential() + { + var options = new DefaultAzureCredentialOptions + { + ExcludeInteractiveBrowserCredential = true, + }; + + var tenantId = Environment.GetEnvironmentVariable("AZURE_TENANT_ID"); + if (!string.IsNullOrEmpty(tenantId)) + { + options.TenantId = tenantId; + } + + return new DefaultAzureCredential(options); + } + + /// Creates the Azure CLI credential used after an interactive 'az login'. Virtual for tests. + protected virtual TokenCredential CreateAzureCliCredential() => new AzureCliCredential(); + + protected virtual string? FindAzureCli() + { + // Check common install locations on Windows + var candidates = new[] + { + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Microsoft SDKs", "Azure", "CLI2", "wbin", "az.cmd"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", "Microsoft SDKs", "Azure", "CLI2", "wbin", "az.cmd"), + }; + + foreach (var candidate in candidates) + { + if (File.Exists(candidate)) + { + return candidate; + } + } + + // Fall back to PATH lookup + try + { + var psi = new ProcessStartInfo + { + FileName = "where.exe", + Arguments = "az.cmd", + UseShellExecute = false, + RedirectStandardOutput = true, + CreateNoWindow = true + }; + using var p = Process.Start(psi); + if (p != null) + { + var output = p.StandardOutput.ReadToEnd().Trim(); + p.WaitForExit(); + if (p.ExitCode == 0 && !string.IsNullOrEmpty(output)) + { + return output.Split('\n')[0].Trim(); + } + } + } + catch + { + // where.exe not available or failed + } + + return null; + } + + protected virtual async Task RunAzLoginAsync(string azPath, string tenantId, CancellationToken cancellationToken) + { + var psi = new ProcessStartInfo + { + FileName = azPath, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = false // Allow browser interaction + }; + + // Use ArgumentList rather than string interpolation so the (already validated) + // tenant value can never be smuggled in as extra arguments to the az.cmd target. + psi.ArgumentList.Add("login"); + psi.ArgumentList.Add("--tenant"); + psi.ArgumentList.Add(tenantId); + + using var p = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start Azure CLI"); + + // Drain and forward both pipes concurrently. Reading neither (the previous behavior) + // can deadlock when az fills a redirected pipe, and forwarding lets the user see + // device-login instructions written to stdout/stderr. + var stdoutTask = ForwardStreamAsync(p.StandardOutput, cancellationToken); + var stderrTask = ForwardStreamAsync(p.StandardError, cancellationToken); + + try + { + await p.WaitForExitAsync(cancellationToken); + } + catch (OperationCanceledException) + { + TryKillProcessTree(p); + throw; + } + + await Task.WhenAll(stdoutTask, stderrTask); + return p.ExitCode == 0; + } + + private async Task ForwardStreamAsync(StreamReader reader, CancellationToken cancellationToken) + { + string? line; + while ((line = await reader.ReadLineAsync(cancellationToken)) != null) + { + ansiConsole.WriteLine(line); + } + } + + private static void TryKillProcessTree(Process process) + { + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + } + catch + { + // Best-effort cleanup on cancellation + } + } + + /// + /// Validates that a tenant identifier is a GUID or a DNS-style domain name. This both + /// prevents bad input from reaching the Azure CLI and rejects shell/argument metacharacters. + /// + internal static bool IsValidTenantId(string tenantId) + { + if (string.IsNullOrWhiteSpace(tenantId)) + { + return false; + } + + return Guid.TryParse(tenantId, out _) || TenantDomainRegex().IsMatch(tenantId); + } + + [GeneratedRegex(@"^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$")] + private static partial Regex TenantDomainRegex(); + + private void LogAuthMethod(AccessToken token) + { + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("AZURE_CLIENT_SECRET"))) + { + logger.LogInformation("Authenticated via environment credentials (service principal)"); + } + else if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("AZURE_FEDERATED_TOKEN_FILE")) + || !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("ACTIONS_ID_TOKEN_REQUEST_URL"))) + { + logger.LogInformation("Authenticated via workload identity (OIDC federation)"); + } + else if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("IDENTITY_ENDPOINT"))) + { + logger.LogInformation("Authenticated via managed identity"); + } + else + { + logger.LogInformation("Authenticated via Azure CLI"); + } + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/AzureSignToolService.cs b/src/winapp-CLI/WinApp.Cli/Services/AzureSignToolService.cs new file mode 100644 index 00000000..1314d58f --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/AzureSignToolService.cs @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using WinApp.Cli.ConsoleTasks; +using WinApp.Cli.Tools; + +namespace WinApp.Cli.Services; + +/// +/// Acquires the Azure Trusted Signing client library and drives signtool to sign a file. +/// +internal class AzureSignToolService( + IBuildToolsService buildToolsService, + INugetService nugetService, + IPackageInstallationService packageInstallationService, + IWinappDirectoryService winappDirectoryService) : IAzureSignToolService +{ + internal const string ArtifactSigningClientPackage = "Microsoft.ArtifactSigning.Client"; + + // Pin to a known-good version so the DLL loaded into signtool is reproducible and + // not silently upgraded to whatever happens to be latest in the NuGet feed. + internal const string ArtifactSigningClientVersion = "1.0.128"; + + private const string TimestampUrl = "http://timestamp.acs.microsoft.com"; + + public async Task SignAsync( + FileInfo filePath, + FileInfo metadataFilePath, + string? tenantId, + TaskContext taskContext, + CancellationToken cancellationToken = default) + { + // Ensure the Trusted Signing dlib is available + var dlibPath = await EnsureTrustedSigningDlibAsync(taskContext, cancellationToken); + + // The dlib only ships as x64/x86. We must use the matching signtool architecture. + // Determine dlib architecture from its path (bin/x64/ or bin/x86/) + var dlibArch = dlibPath.Directory?.Name; // "x64" or "x86" + + // Ensure signtool is installed, then find the matching architecture version + var signtoolPath = await buildToolsService.EnsureBuildToolAvailableAsync("signtool.exe", taskContext, cancellationToken: cancellationToken); + + // If we're on ARM64 and the signtool resolved is arm64, swap to x64 to match the dlib + if (dlibArch != null && !signtoolPath.FullName.Contains($"\\{dlibArch}\\", StringComparison.OrdinalIgnoreCase)) + { + // Try to find the x64 signtool alongside the resolved one + var signtoolDir = signtoolPath.Directory!; + var parentDir = signtoolDir.Parent; + if (parentDir != null) + { + var matchingArchSigntool = new FileInfo(Path.Combine(parentDir.FullName, dlibArch, "signtool.exe")); + if (matchingArchSigntool.Exists) + { + signtoolPath = matchingArchSigntool; + } + } + } + + // Build signtool arguments for Azure Trusted Signing + var arguments = $@"sign /v /debug /fd SHA256 /tr ""{TimestampUrl}"" /td SHA256 /dlib ""{dlibPath.FullName}"" /dmdf ""{metadataFilePath.FullName}"" ""{filePath.FullName}"""; + + taskContext.AddDebugMessage($"Using signtool: {signtoolPath.FullName}"); + taskContext.AddDebugMessage($"Using dlib: {dlibPath.FullName}"); + + // Pass tenant ID to signtool so the dlib's Azure.Identity authenticates against the correct tenant. + IReadOnlyDictionary? environment = !string.IsNullOrEmpty(tenantId) + ? new Dictionary { ["AZURE_TENANT_ID"] = tenantId } + : null; + + // Reuse the shared build-tool runner so process spawning, concurrent stream draining, + // cancellation/kill, and exit-code handling live in one place. We pass the resolved + // (architecture-matched) signtool path as an override rather than re-resolving by name. + await buildToolsService.RunBuildToolAsync( + new GenericTool("signtool.exe"), + arguments, + taskContext, + toolPathOverride: signtoolPath, + environment: environment, + cancellationToken: cancellationToken); + + taskContext.AddDebugMessage("File signed successfully"); + } + + internal async Task EnsureTrustedSigningDlibAsync(TaskContext taskContext, CancellationToken cancellationToken) + { + // Check if already available in NuGet cache + var dlibPath = FindTrustedSigningDlib(ArtifactSigningClientVersion); + if (dlibPath != null) + { + return dlibPath; + } + + // Download the pinned version of the package + await taskContext.AddSubTaskAsync($"Installing {ArtifactSigningClientPackage} {ArtifactSigningClientVersion}...", async (subContext, ct) => + { + var globalWinappDir = winappDirectoryService.GetGlobalWinappDirectory(); + var success = await packageInstallationService.EnsurePackageAsync( + globalWinappDir, + ArtifactSigningClientPackage, + subContext, + version: ArtifactSigningClientVersion, + cancellationToken: ct); + + if (!success) + { + return (1, $"Failed to install {ArtifactSigningClientPackage}."); + } + + return (0, $"{ArtifactSigningClientPackage} installed successfully."); + }, cancellationToken); + + dlibPath = FindTrustedSigningDlib(ArtifactSigningClientVersion); + if (dlibPath == null) + { + throw new InvalidOperationException( + $"Could not find the Trusted Signing client library after installing {ArtifactSigningClientPackage} {ArtifactSigningClientVersion}.\n" + + "Ensure the package contains the expected DLL structure."); + } + + return dlibPath; + } + + private FileInfo? FindTrustedSigningDlib(string? version = null) + { + var nugetCache = nugetService.GetNuGetGlobalPackagesDir(); + var packageDir = new DirectoryInfo(Path.Combine(nugetCache.FullName, ArtifactSigningClientPackage.ToLowerInvariant())); + + if (!packageDir.Exists) + { + return null; + } + + IEnumerable versionDirs; + if (!string.IsNullOrEmpty(version)) + { + // Prefer the exact pinned version so the loaded DLL is reproducible. + var exactDir = new DirectoryInfo(Path.Combine(packageDir.FullName, version)); + if (!exactDir.Exists) + { + return null; + } + versionDirs = [exactDir]; + } + else + { + // Fall back to the highest installed version using semantic (not lexicographic) ordering. + versionDirs = packageDir.GetDirectories() + .OrderByDescending(d => d.Name, Comparer.Create(NugetService.CompareVersions)); + } + + foreach (var versionDir in versionDirs) + { + // The dlib is at: bin/x64/Azure.CodeSigning.Dlib.dll (x64 works on ARM64 via emulation) + var dlibFile = new FileInfo(Path.Combine(versionDir.FullName, "bin", "x64", "Azure.CodeSigning.Dlib.dll")); + if (dlibFile.Exists) + { + return dlibFile; + } + + // Fallback: search recursively for the DLL + var found = versionDir.GetFiles("Azure.CodeSigning.Dlib.dll", SearchOption.AllDirectories).FirstOrDefault(); + if (found != null) + { + return found; + } + } + + return null; + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/AzureSigningService.cs b/src/winapp-CLI/WinApp.Cli/Services/AzureSigningService.cs new file mode 100644 index 00000000..2734e951 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/AzureSigningService.cs @@ -0,0 +1,196 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.Net.Http.Headers; +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace WinApp.Cli.Services; + +/// +/// Calls Azure REST APIs to discover Trusted Signing resources. +/// NativeAOT-compatible — uses raw HTTP with System.Text.Json. +/// +internal class AzureSigningService : IAzureSigningService +{ + private static readonly HttpClient SharedHttp = new() { Timeout = TimeSpan.FromSeconds(30) }; + private const string ArmBaseUrl = "https://management.azure.com"; + private const string SubscriptionsApiVersion = "2022-12-01"; + private const string TrustedSigningApiVersion = "2024-02-05-preview"; + + private readonly ILogger logger; + private readonly HttpClient http; + + public AzureSigningService(ILogger logger) : this(logger, SharedHttp) + { + } + + // Test seam: allows injecting an HttpClient backed by a stub message handler. + internal AzureSigningService(ILogger logger, HttpClient httpClient) + { + this.logger = logger; + this.http = httpClient; + } + + public async Task> ListSubscriptionsAsync(string accessToken, CancellationToken cancellationToken = default) + { + var url = $"{ArmBaseUrl}/subscriptions?api-version={SubscriptionsApiVersion}"; + var json = await GetArmResponseAsync(url, accessToken, cancellationToken); + + var subscriptions = new List(); + using var doc = JsonDocument.Parse(json); + + if (!doc.RootElement.TryGetProperty("value", out var valueArray)) + { + return subscriptions; + } + + foreach (var item in valueArray.EnumerateArray()) + { + var subId = item.GetProperty("subscriptionId").GetString() ?? ""; + var displayName = item.GetProperty("displayName").GetString() ?? ""; + var state = item.TryGetProperty("state", out var stateProp) ? stateProp.GetString() : null; + + // Only include enabled subscriptions + if (string.Equals(state, "Enabled", StringComparison.OrdinalIgnoreCase)) + { + subscriptions.Add(new AzureSubscription(subId, displayName)); + } + } + + logger.LogInformation("Found {Count} enabled subscription(s)", subscriptions.Count); + return subscriptions; + } + + public async Task> ListSigningAccountsAsync(string accessToken, string subscriptionId, string? resourceGroup = null, CancellationToken cancellationToken = default) + { + string url; + if (!string.IsNullOrEmpty(resourceGroup)) + { + url = $"{ArmBaseUrl}/subscriptions/{subscriptionId}/resourceGroups/{resourceGroup}/providers/Microsoft.CodeSigning/codeSigningAccounts?api-version={TrustedSigningApiVersion}"; + } + else + { + url = $"{ArmBaseUrl}/subscriptions/{subscriptionId}/providers/Microsoft.CodeSigning/codeSigningAccounts?api-version={TrustedSigningApiVersion}"; + } + + var json = await GetArmResponseAsync(url, accessToken, cancellationToken); + + var accounts = new List(); + using var doc = JsonDocument.Parse(json); + + if (!doc.RootElement.TryGetProperty("value", out var valueArray)) + { + return accounts; + } + + foreach (var item in valueArray.EnumerateArray()) + { + var name = item.GetProperty("name").GetString() ?? ""; + var location = item.TryGetProperty("location", out var locProp) ? locProp.GetString() ?? "" : ""; + var id = item.GetProperty("id").GetString() ?? ""; + + // Parse resource group from the resource ID + var rg = ParseResourceGroupFromId(id); + + // Get account URI from properties + string? accountUri = null; + if (item.TryGetProperty("properties", out var props) && + props.TryGetProperty("accountUri", out var uriProp)) + { + accountUri = uriProp.GetString(); + } + + accounts.Add(new SigningAccount(name, rg, location, accountUri)); + } + + logger.LogInformation("Found {Count} signing account(s)", accounts.Count); + return accounts; + } + + public async Task> ListCertificateProfilesAsync(string accessToken, string subscriptionId, string resourceGroup, string accountName, CancellationToken cancellationToken = default) + { + var url = $"{ArmBaseUrl}/subscriptions/{subscriptionId}/resourceGroups/{resourceGroup}/providers/Microsoft.CodeSigning/codeSigningAccounts/{accountName}/certificateProfiles?api-version={TrustedSigningApiVersion}"; + + var json = await GetArmResponseAsync(url, accessToken, cancellationToken); + + var profiles = new List(); + using var doc = JsonDocument.Parse(json); + + if (!doc.RootElement.TryGetProperty("value", out var valueArray)) + { + return profiles; + } + + foreach (var item in valueArray.EnumerateArray()) + { + var name = item.GetProperty("name").GetString() ?? ""; + var profileType = ""; + var status = ""; + + if (item.TryGetProperty("properties", out var props)) + { + profileType = props.TryGetProperty("profileType", out var typeProp) ? typeProp.GetString() ?? "" : ""; + status = props.TryGetProperty("status", out var statusProp) ? statusProp.GetString() ?? "" : ""; + } + + profiles.Add(new CertificateProfile(name, profileType, status)); + } + + logger.LogInformation("Found {Count} certificate profile(s) for account '{Account}'", profiles.Count, accountName); + return profiles; + } + + private async Task GetArmResponseAsync(string url, string accessToken, CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + var response = await http.SendAsync(request, cancellationToken); + var content = await response.Content.ReadAsStringAsync(cancellationToken); + + if (!response.IsSuccessStatusCode) + { + var errorMessage = TryParseAzureError(content) ?? $"HTTP {(int)response.StatusCode}: {response.ReasonPhrase}"; + throw new InvalidOperationException($"Azure API request failed: {errorMessage}"); + } + + return content; + } + + private static string? TryParseAzureError(string responseBody) + { + try + { + using var doc = JsonDocument.Parse(responseBody); + if (doc.RootElement.TryGetProperty("error", out var errorObj)) + { + var code = errorObj.TryGetProperty("code", out var codeProp) ? codeProp.GetString() : null; + var message = errorObj.TryGetProperty("message", out var msgProp) ? msgProp.GetString() : null; + if (message != null) + { + return code != null ? $"{code}: {message}" : message; + } + } + } + catch + { + // Not valid JSON or unexpected structure + } + return null; + } + + private static string ParseResourceGroupFromId(string resourceId) + { + // Resource ID format: /subscriptions/{sub}/resourceGroups/{rg}/providers/... + var parts = resourceId.Split('/', StringSplitOptions.RemoveEmptyEntries); + for (int i = 0; i < parts.Length - 1; i++) + { + if (string.Equals(parts[i], "resourceGroups", StringComparison.OrdinalIgnoreCase)) + { + return parts[i + 1]; + } + } + return ""; + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/BuildToolsService.cs b/src/winapp-CLI/WinApp.Cli/Services/BuildToolsService.cs index cdcfb3cc..41d88e25 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/BuildToolsService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/BuildToolsService.cs @@ -310,14 +310,18 @@ await taskContext.AddSubTaskAsync($"{actionMessage} {BUILD_TOOLS_PACKAGE}{versio /// Arguments to pass to the tool /// Whether to print errors using the tool's PrintErrorText method /// Task context for logging + /// Explicit executable path to run instead of resolving the tool by name + /// Additional environment variables to set on the child process /// Cancellation token /// Tuple containing (stdout, stderr) - public async Task<(string stdout, string stderr)> RunBuildToolAsync(Tool tool, string arguments, TaskContext taskContext, bool printErrors = true, CancellationToken cancellationToken = default) + public async Task<(string stdout, string stderr)> RunBuildToolAsync(Tool tool, string arguments, TaskContext taskContext, bool printErrors = true, FileInfo? toolPathOverride = null, IReadOnlyDictionary? environment = null, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); - // Ensure the build tool is available, installing BuildTools if necessary - var toolPath = await EnsureBuildToolAvailableAsync(tool.ExecutableName, taskContext, cancellationToken: cancellationToken); + // Use the caller-supplied executable when provided (e.g. an architecture-matched + // signtool), otherwise ensure the build tool is available, installing BuildTools if necessary. + var toolPath = toolPathOverride + ?? await EnsureBuildToolAvailableAsync(tool.ExecutableName, taskContext, cancellationToken: cancellationToken); var psi = new ProcessStartInfo { @@ -329,12 +333,36 @@ await taskContext.AddSubTaskAsync($"{actionMessage} {BUILD_TOOLS_PACKAGE}{versio CreateNoWindow = true }; + if (environment != null) + { + foreach (var (key, value) in environment) + { + psi.Environment[key] = value; + } + } + cancellationToken.ThrowIfCancellationRequested(); using var p = Process.Start(psi) ?? throw new InvalidOperationException($"Failed to start {tool.ExecutableName} process"); - var stdout = await p.StandardOutput.ReadToEndAsync(cancellationToken); - var stderr = await p.StandardError.ReadToEndAsync(cancellationToken); - await p.WaitForExitAsync(cancellationToken); + + // Drain both pipes concurrently before awaiting exit. Reading stdout to completion + // before touching stderr can deadlock if the tool fills the stderr buffer (signtool + // /v /debug can) while we're still blocked on stdout. + var stdoutTask = p.StandardOutput.ReadToEndAsync(cancellationToken); + var stderrTask = p.StandardError.ReadToEndAsync(cancellationToken); + + try + { + await p.WaitForExitAsync(cancellationToken); + } + catch (OperationCanceledException) + { + TryKillProcessTree(p); + throw; + } + + var stdout = await stdoutTask; + var stderr = await stderrTask; if (!string.IsNullOrWhiteSpace(stdout)) { @@ -361,6 +389,21 @@ await taskContext.AddSubTaskAsync($"{actionMessage} {BUILD_TOOLS_PACKAGE}{versio return (stdout, stderr); } + private static void TryKillProcessTree(Process process) + { + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + } + catch + { + // Best-effort cleanup on cancellation + } + } + internal class InvalidBuildToolException : InvalidOperationException { public InvalidBuildToolException(int processId, string stdout, string stderr, string message) : base(message) diff --git a/src/winapp-CLI/WinApp.Cli/Services/IAzureAuthService.cs b/src/winapp-CLI/WinApp.Cli/Services/IAzureAuthService.cs new file mode 100644 index 00000000..cecbbbbf --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/IAzureAuthService.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +namespace WinApp.Cli.Services; + +/// +/// Provides Azure authentication tokens for ARM and Trusted Signing data plane operations. +/// +internal interface IAzureAuthService +{ + /// + /// Acquires an access token for the specified resource scope. + /// Tries environment credentials, workload identity, managed identity, and then Azure CLI. + /// + /// The OAuth2 scope (e.g., "https://management.azure.com/.default") + /// Cancellation token + /// A valid bearer token + /// When authentication fails in a non-interactive environment + Task GetAccessTokenAsync(string scope, CancellationToken cancellationToken = default); + + /// + /// Whether the current environment supports interactive authentication. + /// + bool IsInteractive { get; } + + /// + /// The Azure tenant ID used for authentication, if known. + /// Set from AZURE_TENANT_ID environment variable or user input during interactive login. + /// + string? TenantId { get; } +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/IAzureSignToolService.cs b/src/winapp-CLI/WinApp.Cli/Services/IAzureSignToolService.cs new file mode 100644 index 00000000..a8f9f1cf --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/IAzureSignToolService.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using WinApp.Cli.ConsoleTasks; + +namespace WinApp.Cli.Services; + +/// +/// Runs signtool with the Azure Trusted Signing dlib to code-sign a file. +/// Owns acquisition of the Trusted Signing client package and signtool invocation, +/// keeping that logic out of the command handler so it can be substituted in tests. +/// +internal interface IAzureSignToolService +{ + /// + /// Signs using the supplied Trusted Signing metadata file. + /// + /// The file to sign (exe, msix, or msixbundle). + /// The Trusted Signing metadata.json file. + /// Azure tenant ID to forward to signtool's dlib, if known. + /// Status/progress context. + /// Cancellation token. + /// When signing fails. + Task SignAsync( + FileInfo filePath, + FileInfo metadataFilePath, + string? tenantId, + TaskContext taskContext, + CancellationToken cancellationToken = default); +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/IAzureSigningService.cs b/src/winapp-CLI/WinApp.Cli/Services/IAzureSigningService.cs new file mode 100644 index 00000000..56bcb9c7 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/IAzureSigningService.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +namespace WinApp.Cli.Services; + +/// +/// Provides access to Azure Trusted Signing REST APIs for discovering +/// subscriptions, signing accounts, and certificate profiles. +/// +internal interface IAzureSigningService +{ + /// + /// Lists all Azure subscriptions accessible to the authenticated user. + /// + Task> ListSubscriptionsAsync(string accessToken, CancellationToken cancellationToken = default); + + /// + /// Lists Trusted Signing accounts. If resourceGroup is provided, lists only within that group. + /// Otherwise lists all signing accounts in the subscription. + /// + Task> ListSigningAccountsAsync(string accessToken, string subscriptionId, string? resourceGroup = null, CancellationToken cancellationToken = default); + + /// + /// Lists certificate profiles under a signing account. + /// + Task> ListCertificateProfilesAsync(string accessToken, string subscriptionId, string resourceGroup, string accountName, CancellationToken cancellationToken = default); +} + +internal record AzureSubscription(string SubscriptionId, string DisplayName); + +internal record SigningAccount(string Name, string ResourceGroup, string Location, string? AccountUri); + +internal record CertificateProfile(string Name, string ProfileType, string Status); diff --git a/src/winapp-CLI/WinApp.Cli/Services/IBuildToolsService.cs b/src/winapp-CLI/WinApp.Cli/Services/IBuildToolsService.cs index 4448baab..b6a8040c 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/IBuildToolsService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/IBuildToolsService.cs @@ -38,7 +38,9 @@ internal interface IBuildToolsService /// Arguments to pass to the tool /// The task context for logging /// Whether to print errors using the tool's PrintErrorText method + /// Explicit executable path to run instead of resolving the tool by name (e.g. an architecture-matched signtool) + /// Additional environment variables to set on the child process /// Cancellation token /// Tuple containing (stdout, stderr) - Task<(string stdout, string stderr)> RunBuildToolAsync(Tool tool, string arguments, TaskContext taskContext, bool printErrors = true, CancellationToken cancellationToken = default); + Task<(string stdout, string stderr)> RunBuildToolAsync(Tool tool, string arguments, TaskContext taskContext, bool printErrors = true, FileInfo? toolPathOverride = null, IReadOnlyDictionary? environment = null, CancellationToken cancellationToken = default); } diff --git a/src/winapp-CLI/WinApp.Cli/WinApp.Cli.csproj b/src/winapp-CLI/WinApp.Cli/WinApp.Cli.csproj index b56da957..84b68ed5 100644 --- a/src/winapp-CLI/WinApp.Cli/WinApp.Cli.csproj +++ b/src/winapp-CLI/WinApp.Cli/WinApp.Cli.csproj @@ -53,6 +53,7 @@ + diff --git a/src/winapp-VSC/package.json b/src/winapp-VSC/package.json index 708bd023..402ca746 100644 --- a/src/winapp-VSC/package.json +++ b/src/winapp-VSC/package.json @@ -28,6 +28,7 @@ "onCommand:winapp.certGenerate", "onCommand:winapp.certInstall", "onCommand:winapp.sign", + "onCommand:winapp.azSign", "onCommand:winapp.tool", "onCommand:winapp.getWinappPath", "onCommand:winapp.manifestAddAlias", @@ -92,6 +93,11 @@ "title": "Sign Package", "category": "WinApp" }, + { + "command": "winapp.azSign", + "title": "Sign Package with Azure Trusted Signing", + "category": "WinApp" + }, { "command": "winapp.tool", "title": "Run SDK Tool", diff --git a/src/winapp-VSC/src/extension.ts b/src/winapp-VSC/src/extension.ts index 707a021c..bcf31507 100644 --- a/src/winapp-VSC/src/extension.ts +++ b/src/winapp-VSC/src/extension.ts @@ -660,6 +660,31 @@ export function activate(context: vscode.ExtensionContext) { }) ); + // Register winapp.azSign command (Azure Trusted Signing) + context.subscriptions.push( + vscode.commands.registerCommand('winapp.azSign', async () => { + const workspacePath = getWorkspacePath(); + if (!workspacePath) { + return; + } + + const filePath = await selectFile('Select file to sign with Azure Trusted Signing', { + 'MSIX Packages': ['msix', 'msixbundle', 'appx'], + 'Executables': ['exe', 'dll'], + 'All files': ['*'] + }); + + if (!filePath) { + vscode.window.showErrorMessage('A file to sign is required'); + return; + } + + // Signing identity is discovered interactively (subscription/account/profile) by the + // CLI, and authentication uses the standard Azure credential chain. No extra input needed. + await runWinappCommand(extensionPath, `az-sign "${filePath}"`, workspacePath); + }) + ); + // Register winapp.tool command context.subscriptions.push( vscode.commands.registerCommand('winapp.tool', async () => { diff --git a/src/winapp-npm/src/winapp-commands.ts b/src/winapp-npm/src/winapp-commands.ts index c120c800..3bd0363c 100644 --- a/src/winapp-npm/src/winapp-commands.ts +++ b/src/winapp-npm/src/winapp-commands.ts @@ -66,6 +66,39 @@ async function execCommand(args: string[], opts: CommonOptions): Promise { + const args: string[] = ['az-sign']; + args.push(options.filePath); + if (options.account) args.push('--account', options.account); + if (options.metadataFile) args.push('--metadata-file', options.metadataFile); + if (options.profile) args.push('--profile', options.profile); + if (options.resourceGroup) args.push('--resource-group', options.resourceGroup); + if (options.subscription) args.push('--subscription', options.subscription); + return execCommand(args, options); +} + // --------------------------------------------------------------------------- // cert generate // ---------------------------------------------------------------------------