diff --git a/Cargo.lock b/Cargo.lock index 7fce58c..0a32003 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2092,9 +2092,12 @@ dependencies = [ "anyhow", "base64", "mockito", + "rcgen", "reqwest", "serde", "serde_json", + "url", + "x509-cert", ] [[package]] diff --git a/crates/psign-codesigning-rest/Cargo.toml b/crates/psign-codesigning-rest/Cargo.toml index 8314e6a..410d45e 100644 --- a/crates/psign-codesigning-rest/Cargo.toml +++ b/crates/psign-codesigning-rest/Cargo.toml @@ -12,8 +12,11 @@ base64 = "0.22" reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +url = "2" +x509-cert = "0.2.5" [dev-dependencies] base64 = "0.22" mockito = "1.6" +rcgen = "0.13" serde_json = "1" diff --git a/crates/psign-codesigning-rest/src/lib.rs b/crates/psign-codesigning-rest/src/lib.rs index 3a0ce8e..b10a3cf 100644 --- a/crates/psign-codesigning-rest/src/lib.rs +++ b/crates/psign-codesigning-rest/src/lib.rs @@ -5,12 +5,21 @@ use anyhow::{Context as _, Result, anyhow}; use base64::Engine as _; use serde::Deserialize; use serde_json::Value; +use std::io::Read; use std::thread; use std::time::Duration; +use url::Url; +use x509_cert::{Certificate, der::Decode as _, ext::pkix::BasicConstraints}; pub const DEFAULT_API_VERSION: &str = "2024-06-15"; +/// Preview API version used by the profile root-certificate operation. +pub const DEFAULT_PROFILE_ROOT_API_VERSION: &str = "2022-06-15-preview"; const DEFAULT_SCOPE: &str = "https://codesigning.azure.net/.default"; const MI_RESOURCE: &str = "https://codesigning.azure.net"; +const MAX_PROFILE_ROOT_BYTES: usize = 1024 * 1024; +const MAX_PROFILE_ROOT_ERROR_BYTES: usize = 64 * 1024; +const AZURE_DATA_PLANE_SUFFIXES: [&str; 2] = + [".codesigning.azure.net", ".artifactsigning.azure.net"]; /// Authentication mode for **`codesigning.azure.net`**. #[derive(Debug, Clone)] @@ -75,6 +84,18 @@ pub struct CodesigningSubmitParams { pub endpoint_base_url: Option, } +/// Parameters for authenticated certificate-profile metadata reads. +#[derive(Debug, Clone)] +pub struct CodesigningProfileParams { + pub account_name: String, + pub profile_name: String, + pub api_version: String, + pub authority: Option, + pub auth: CodesigningAuth, + /// HTTPS data-plane origin in the public Azure cloud, without a path, query, or fragment. + pub endpoint_base_url: String, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct CodesigningSignatureResult { pub signature: Vec, @@ -418,8 +439,8 @@ fn acquire_workload_identity_token( Ok(j.access_token) } -fn acquire_codesigning_token(params: &CodesigningSubmitParams) -> Result { - match ¶ms.auth { +fn acquire_codesigning_token(auth: &CodesigningAuth, authority: Option<&str>) -> Result { + match auth { CodesigningAuth::Bearer(tok) => { let t = tok.trim(); if t.is_empty() { @@ -435,22 +456,12 @@ fn acquire_codesigning_token(params: &CodesigningSubmitParams) -> Result tenant_id, client_id, client_secret, - } => acquire_client_credentials_token( - params.authority.as_deref(), - tenant_id, - client_id, - client_secret, - ), + } => acquire_client_credentials_token(authority, tenant_id, client_id, client_secret), CodesigningAuth::WorkloadIdentity { tenant_id, client_id, federated_token_file, - } => acquire_workload_identity_token( - params.authority.as_deref(), - tenant_id, - client_id, - federated_token_file, - ), + } => acquire_workload_identity_token(authority, tenant_id, client_id, federated_token_file), CodesigningAuth::DefaultChain { exclude_credentials, } => { @@ -463,12 +474,7 @@ fn acquire_codesigning_token(params: &CodesigningSubmitParams) -> Result env_text("AZURE_CLIENT_ID"), env_text("AZURE_CLIENT_SECRET"), ) { - match acquire_client_credentials_token( - params.authority.as_deref(), - &tenant, - &client, - &secret, - ) { + match acquire_client_credentials_token(authority, &tenant, &client, &secret) { Ok(token) => return Ok(token), Err(e) => errors.push(format!("EnvironmentCredential: {e:#}")), } @@ -480,12 +486,7 @@ fn acquire_codesigning_token(params: &CodesigningSubmitParams) -> Result env_text("AZURE_FEDERATED_TOKEN_FILE"), ) { - match acquire_workload_identity_token( - params.authority.as_deref(), - &tenant, - &client, - &token_file, - ) { + match acquire_workload_identity_token(authority, &tenant, &client, &token_file) { Ok(token) => return Ok(token), Err(e) => errors.push(format!("WorkloadIdentityCredential: {e:#}")), } @@ -553,7 +554,7 @@ pub fn submit_codesign_hash_blocking( return Err(anyhow!("digest is empty")); } - let token = acquire_codesigning_token(params)?; + let token = acquire_codesigning_token(¶ms.auth, params.authority.as_deref())?; let http = reqwest::blocking::Client::builder() .timeout(Duration::from_secs(300)) .build() @@ -632,6 +633,160 @@ pub fn submit_codesign_hash_blocking( poll_operation(&http, &token, &poll_url) } +/// Retrieve the root certificate currently associated with an Artifact Signing profile. +pub fn get_codesigning_root_certificate_blocking( + params: &CodesigningProfileParams, +) -> Result> { + let url = profile_root_certificate_url(params)?; + let token = acquire_codesigning_token(¶ms.auth, params.authority.as_deref())?; + let http = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(30)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|e| anyhow!("HTTP client: {e}"))?; + fetch_profile_root_certificate(&http, &token, url) +} + +fn profile_root_certificate_url(params: &CodesigningProfileParams) -> Result { + let account = params.account_name.trim(); + let profile = params.profile_name.trim(); + let api = params.api_version.trim(); + if account.is_empty() || profile.is_empty() || api.is_empty() { + return Err(anyhow!( + "Artifact Signing account, profile, and API version must not be empty" + )); + } + + let mut url = validated_artifact_signing_origin(¶ms.endpoint_base_url)?; + url.path_segments_mut() + .map_err(|_| anyhow!("Artifact Signing endpoint cannot be a base URL"))? + .extend([ + "codesigningaccounts", + account, + "certificateprofiles", + profile, + "sign", + "rootcert", + ]); + url.query_pairs_mut().append_pair("api-version", api); + Ok(url) +} + +fn validated_artifact_signing_origin(endpoint: &str) -> Result { + let url = Url::parse(endpoint.trim()).context("parse Artifact Signing endpoint")?; + if url.scheme() != "https" { + return Err(anyhow!("Artifact Signing endpoint must use HTTPS")); + } + if !url.username().is_empty() || url.password().is_some() { + return Err(anyhow!( + "Artifact Signing endpoint must not contain user information" + )); + } + if url.port().is_some_and(|port| port != 443) { + return Err(anyhow!( + "Artifact Signing endpoint must use the default HTTPS port" + )); + } + if url.path() != "/" || url.query().is_some() || url.fragment().is_some() { + return Err(anyhow!( + "Artifact Signing endpoint must be an origin without a path, query, or fragment" + )); + } + let host = url + .host_str() + .ok_or_else(|| anyhow!("Artifact Signing endpoint must include a host"))?; + if !AZURE_DATA_PLANE_SUFFIXES + .iter() + .any(|suffix| host.ends_with(suffix) && host.len() > suffix.len()) + { + return Err(anyhow!( + "Artifact Signing endpoint host must be a public Azure Artifact Signing data-plane host" + )); + } + Ok(url) +} + +fn fetch_profile_root_certificate( + http: &reqwest::blocking::Client, + token: &str, + url: Url, +) -> Result> { + let response = http + .get(url) + .header("Authorization", format!("Bearer {token}")) + .header("Accept", "application/x-x509-ca-cert") + .send() + .context("Artifact Signing root certificate GET")?; + let status = response.status(); + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(ToOwned::to_owned); + let max_body_bytes = if status.is_success() { + MAX_PROFILE_ROOT_BYTES + } else { + MAX_PROFILE_ROOT_ERROR_BYTES + }; + let body = read_bounded_body(response, max_body_bytes).with_context(|| { + format!("read Artifact Signing root certificate HTTP {status} response") + })?; + if !status.is_success() { + return Err(anyhow!( + "Artifact Signing root certificate HTTP {}: {}", + status, + String::from_utf8_lossy(&body) + )); + } + validate_root_certificate_response(content_type.as_deref(), &body)?; + Ok(body.to_vec()) +} + +fn validate_root_certificate_response(content_type: Option<&str>, body: &[u8]) -> Result<()> { + if body.is_empty() { + return Err(anyhow!( + "Artifact Signing returned an empty root certificate" + )); + } + if let Some(content_type) = content_type { + let media_type = content_type.split(';').next().unwrap_or_default().trim(); + if !matches!( + media_type, + "application/x-x509-ca-cert" | "application/pkix-cert" | "application/octet-stream" + ) { + return Err(anyhow!( + "Artifact Signing returned unexpected root certificate content type {media_type}" + )); + } + } + let certificate = + Certificate::from_der(body).context("parse Artifact Signing root certificate DER")?; + let basic_constraints = certificate + .tbs_certificate + .get::() + .context("parse Artifact Signing root certificate Basic Constraints")?; + if !basic_constraints.is_some_and(|(_, constraints)| constraints.ca) { + return Err(anyhow!( + "Artifact Signing returned a certificate that is not a CA" + )); + } + Ok(()) +} + +fn read_bounded_body(reader: impl Read, max_bytes: usize) -> Result> { + let mut body = Vec::new(); + reader + .take(max_bytes as u64 + 1) + .read_to_end(&mut body) + .context("read HTTP response body")?; + if body.len() > max_bytes { + return Err(anyhow!( + "Artifact Signing root certificate response exceeds {max_bytes} bytes" + )); + } + Ok(body) +} + fn sign_result_object(v: &Value) -> &Value { v.get("result").unwrap_or(v) } @@ -668,6 +823,167 @@ pub fn submit_codesign_hash_signature_blocking( #[cfg(test)] mod tests { use super::*; + use mockito::{Matcher, Server}; + use rcgen::{CertificateParams, KeyPair}; + + const TEST_ROOT_DER: &[u8] = + include_bytes!("../../../tests/fixtures/devolutions-authenticode/authenticode-test-ca.crt"); + + fn profile_params(endpoint: &str) -> CodesigningProfileParams { + CodesigningProfileParams { + account_name: "the account".into(), + profile_name: "the/profile".into(), + api_version: DEFAULT_PROFILE_ROOT_API_VERSION.into(), + authority: None, + auth: CodesigningAuth::Bearer("fake-token".into()), + endpoint_base_url: endpoint.into(), + } + } + + #[test] + fn profile_root_url_accepts_supported_azure_origins_and_encodes_names() { + for endpoint in [ + "https://wus.codesigning.azure.net", + "https://wus.artifactsigning.azure.net/", + ] { + let url = profile_root_certificate_url(&profile_params(endpoint)).unwrap(); + let expected_origin = endpoint.trim_end_matches('/'); + assert_eq!( + url.as_str(), + format!( + "{expected_origin}/codesigningaccounts/the%20account/certificateprofiles/\ + the%2Fprofile/sign/rootcert?api-version=2022-06-15-preview" + ) + ); + } + } + + #[test] + fn profile_root_url_rejects_untrusted_or_non_origin_endpoints() { + for endpoint in [ + "http://wus.codesigning.azure.net", + "https://example.com", + "https://codesigning.azure.net", + "https://user@wus.codesigning.azure.net", + "https://wus.codesigning.azure.net:444", + "https://wus.codesigning.azure.net/path", + "https://wus.codesigning.azure.net?query=value", + "https://wus.codesigning.azure.net#fragment", + ] { + assert!( + profile_root_certificate_url(&profile_params(endpoint)).is_err(), + "accepted unsafe endpoint {endpoint}" + ); + } + } + + #[test] + fn endpoint_is_validated_before_credentials_are_acquired() { + let mut params = profile_params("http://example.com"); + params.auth = CodesigningAuth::Bearer(" ".into()); + + let error = get_codesigning_root_certificate_blocking(¶ms).unwrap_err(); + assert!(error.to_string().contains("must use HTTPS"), "{error:#}"); + } + + #[test] + fn fetches_and_validates_profile_root_certificate() { + let mut server = Server::new(); + let root_mock = server + .mock( + "GET", + "/codesigningaccounts/theacct/certificateprofiles/theprof/sign/rootcert", + ) + .match_query(Matcher::UrlEncoded( + "api-version".into(), + DEFAULT_PROFILE_ROOT_API_VERSION.into(), + )) + .match_header("authorization", "Bearer fake-token") + .with_status(200) + .with_header("content-type", "application/x-x509-ca-cert") + .with_body(TEST_ROOT_DER) + .create(); + let url = Url::parse(&format!( + "{}/codesigningaccounts/theacct/certificateprofiles/theprof/sign/rootcert?api-version={}", + server.url(), + DEFAULT_PROFILE_ROOT_API_VERSION + )) + .unwrap(); + let http = reqwest::blocking::Client::new(); + + let root = fetch_profile_root_certificate(&http, "fake-token", url).unwrap(); + + assert_eq!(root, TEST_ROOT_DER); + root_mock.assert(); + } + + #[test] + fn root_certificate_response_rejects_json_and_invalid_der() { + let json_error = validate_root_certificate_response( + Some("application/json; charset=utf-8"), + br#"{"error":"not a certificate"}"#, + ) + .unwrap_err(); + assert!( + json_error.to_string().contains("unexpected"), + "{json_error:#}" + ); + + let der_error = + validate_root_certificate_response(Some("application/x-x509-ca-cert"), b"not DER") + .unwrap_err(); + assert!( + der_error.to_string().contains("certificate DER"), + "{der_error:#}" + ); + + let empty_error = validate_root_certificate_response(None, &[]).unwrap_err(); + assert!(empty_error.to_string().contains("empty"), "{empty_error:#}"); + } + + #[test] + fn root_certificate_response_rejects_non_ca_certificate() { + let key = KeyPair::generate().expect("leaf key"); + let params = CertificateParams::new(vec!["leaf.test".into()]).expect("leaf params"); + let leaf = params.self_signed(&key).expect("self-signed leaf"); + + let error = + validate_root_certificate_response(Some("application/x-x509-ca-cert"), leaf.der()) + .unwrap_err(); + + assert!(error.to_string().contains("not a CA"), "{error:#}"); + } + + #[test] + fn response_body_reader_enforces_the_byte_limit() { + assert_eq!(read_bounded_body(&b"1234"[..], 4).unwrap(), b"1234"); + + let error = read_bounded_body(&b"12345"[..], 4).unwrap_err(); + assert!(error.to_string().contains("exceeds 4 bytes"), "{error:#}"); + } + + #[test] + fn profile_root_http_error_includes_status_and_body() { + let mut server = Server::new(); + let root_mock = server + .mock("GET", "/rootcert") + .with_status(403) + .with_header("content-type", "application/json") + .with_body(r#"{"error":"forbidden"}"#) + .create(); + let http = reqwest::blocking::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap(); + let url = Url::parse(&format!("{}/rootcert", server.url())).unwrap(); + + let error = fetch_profile_root_certificate(&http, "fake-token", url).unwrap_err(); + + let message = format!("{error:#}"); + assert!(message.contains("403"), "{message}"); + assert!(message.contains("forbidden"), "{message}"); + root_mock.assert(); + } #[test] fn bearer_empty_rejected() { @@ -683,7 +999,7 @@ mod tests { auth: CodesigningAuth::Bearer(" ".into()), endpoint_base_url: None, }; - assert!(acquire_codesigning_token(&p).is_err()); + assert!(acquire_codesigning_token(&p.auth, p.authority.as_deref()).is_err()); } #[test] diff --git a/crates/psign-digest-cli/src/main.rs b/crates/psign-digest-cli/src/main.rs index cd7aaae..cb36a83 100644 --- a/crates/psign-digest-cli/src/main.rs +++ b/crates/psign-digest-cli/src/main.rs @@ -23,9 +23,10 @@ use psign_azure_kv_rest::{ }; #[cfg(feature = "artifact-signing-rest")] use psign_codesigning_rest::{ - CodesigningAuth, CodesigningAuthInput, CodesigningCredentialType, CodesigningSubmitParams, - DEFAULT_API_VERSION, resolve_codesigning_auth, submit_codesign_hash_blocking, - submit_codesign_hash_signature_blocking, + CodesigningAuth, CodesigningAuthInput, CodesigningCredentialType, CodesigningProfileParams, + CodesigningSubmitParams, DEFAULT_API_VERSION, DEFAULT_PROFILE_ROOT_API_VERSION, + get_codesigning_root_certificate_blocking, resolve_codesigning_auth, + submit_codesign_hash_blocking, submit_codesign_hash_signature_blocking, }; use psign_opc_sign::{nuget, vsix}; use psign_sip_digest::cab_digest::{self, @@ -2448,6 +2449,12 @@ enum Command { #[command(flatten)] args: ArtifactSigningSubmitPortableArgs, }, + /// Retrieve the root certificate currently associated with an authenticated Artifact Signing profile. + #[cfg(feature = "artifact-signing-rest")] + ArtifactSigningRoot { + #[command(flatten)] + args: ArtifactSigningRootPortableArgs, + }, /// Azure Key Vault **`keys/sign`** over a **precomputed digest file** (RSA PKCS#1 or ECDSA). Requires **`--features azure-kv-sign-portable`**. Does **not** embed Authenticode — use **`psign-tool`** for that. #[cfg(feature = "azure-kv-sign-portable")] AzureKeyVaultSignDigest { @@ -3193,21 +3200,7 @@ enum ArtifactSigningCredentialType { #[cfg(feature = "artifact-signing-rest")] #[derive(Args, Debug, Clone)] -struct ArtifactSigningSubmitPortableArgs { - #[arg(long)] - region: String, - #[arg(long)] - account_name: String, - #[arg(long)] - profile_name: String, - #[arg(long)] - digest_file: PathBuf, - #[arg(long, default_value = "RS256")] - signature_algorithm: String, - #[arg(long, default_value = DEFAULT_API_VERSION)] - api_version: String, - #[arg(long)] - correlation_id: Option, +struct ArtifactSigningAuthPortableArgs { #[arg(long)] access_token: Option, #[arg(long)] @@ -3226,11 +3219,52 @@ struct ArtifactSigningSubmitPortableArgs { federated_token_file: Option, #[arg(long)] authority: Option, +} + +#[cfg(feature = "artifact-signing-rest")] +#[derive(Args, Debug, Clone)] +struct ArtifactSigningSubmitPortableArgs { + #[arg(long)] + region: String, + #[arg(long)] + account_name: String, + #[arg(long)] + profile_name: String, + #[arg(long)] + digest_file: PathBuf, + #[arg(long, default_value = "RS256")] + signature_algorithm: String, + #[arg(long, default_value = DEFAULT_API_VERSION)] + api_version: String, + #[arg(long)] + correlation_id: Option, + #[command(flatten)] + auth: ArtifactSigningAuthPortableArgs, /// Override data-plane origin for deterministic local tests. #[arg(long, hide = true)] endpoint_base_url: Option, } +#[cfg(feature = "artifact-signing-rest")] +#[derive(Args, Debug, Clone)] +struct ArtifactSigningRootPortableArgs { + /// Public Azure Artifact Signing data-plane origin, for example + /// `https://wus.artifactsigning.azure.net`. + #[arg(long)] + endpoint: String, + #[arg(long)] + account_name: String, + #[arg(long)] + profile_name: String, + /// Preview API used by the profile root-certificate operation. + #[arg(long, default_value = DEFAULT_PROFILE_ROOT_API_VERSION)] + api_version: String, + #[arg(long)] + output: PathBuf, + #[command(flatten)] + auth: ArtifactSigningAuthPortableArgs, +} + #[derive(Args, Debug, Clone, Default)] struct ArtifactSigningPortableOptions { /// Artifact Signing metadata JSON (same shape as Microsoft's dlib /dmdf file). @@ -3277,12 +3311,14 @@ struct ArtifactSigningPortableOptions { #[cfg(feature = "artifact-signing-rest")] fn validate_portable_submit_args(args: &ArtifactSigningSubmitPortableArgs) -> Result<()> { - portable_submit_auth(args)?; + portable_artifact_signing_auth(&args.auth)?; Ok(()) } #[cfg(feature = "artifact-signing-rest")] -fn portable_submit_auth(args: &ArtifactSigningSubmitPortableArgs) -> Result { +fn portable_artifact_signing_auth( + args: &ArtifactSigningAuthPortableArgs, +) -> Result { portable_submit_auth_parts( args.access_token.as_deref(), args.managed_identity, @@ -3304,7 +3340,7 @@ fn run_portable_artifact_signing_submit(args: ArtifactSigningSubmitPortableArgs) if digest.is_empty() { return Err(anyhow!("digest file is empty")); } - let auth = portable_submit_auth(&args)?; + let auth = portable_artifact_signing_auth(&args.auth)?; let params = CodesigningSubmitParams { region: args.region, account_name: args.account_name, @@ -3313,7 +3349,7 @@ fn run_portable_artifact_signing_submit(args: ArtifactSigningSubmitPortableArgs) signature_algorithm: args.signature_algorithm, api_version: args.api_version, correlation_id: args.correlation_id, - authority: args.authority, + authority: args.auth.authority, auth, endpoint_base_url: args.endpoint_base_url, }; @@ -3327,6 +3363,26 @@ fn run_portable_artifact_signing_submit(args: ArtifactSigningSubmitPortableArgs) Ok(()) } +#[cfg(feature = "artifact-signing-rest")] +fn run_portable_artifact_signing_root(args: ArtifactSigningRootPortableArgs) -> Result<()> { + let auth = portable_artifact_signing_auth(&args.auth)?; + let params = CodesigningProfileParams { + account_name: args.account_name, + profile_name: args.profile_name, + api_version: args.api_version, + authority: args.auth.authority, + auth, + endpoint_base_url: args.endpoint, + }; + let root = get_codesigning_root_certificate_blocking(¶ms)?; + psign_authenticode_trust::anchor::parse_cert_bytes(&root) + .context("parse Artifact Signing root certificate")?; + std::fs::write(&args.output, root) + .with_context(|| format!("write {}", args.output.display()))?; + println!("output={}", args.output.display()); + Ok(()) +} + #[cfg(feature = "azure-kv-sign-portable")] #[derive(Args, Debug, Clone)] struct AzureKvSignDigestPortableArgs { @@ -5298,6 +5354,10 @@ where Command::ArtifactSigningSubmit { args } => { run_portable_artifact_signing_submit(args)?; } + #[cfg(feature = "artifact-signing-rest")] + Command::ArtifactSigningRoot { args } => { + run_portable_artifact_signing_root(args)?; + } #[cfg(feature = "azure-kv-sign-portable")] Command::AzureKeyVaultSignDigest { args } => { run_portable_azure_kv_sign_digest(args)?; diff --git a/docs/migration-artifact-signing.md b/docs/migration-artifact-signing.md index 241d30a..a6d417c 100644 --- a/docs/migration-artifact-signing.md +++ b/docs/migration-artifact-signing.md @@ -40,6 +40,23 @@ cargo build -p psign-digest-cli --features artifact-signing-rest --locked Optional debug logs: **`SIGNTOOL_PORTABLE_DEBUG=1`**. +### Retrieve the profile root certificate + +The feature-gated **`artifact-signing-root`** helper retrieves the root certificate currently associated with a certificate profile and validates the bounded response as an X.509 DER CA certificate before writing it: + +```bash +psign-tool artifact-signing-root \ + --endpoint https://wus.artifactsigning.azure.net \ + --account-name myAccount \ + --profile-name myProfile \ + --output ./artifact-signing-root.cer \ + --managed-identity +``` + +The caller needs access to the profile; Microsoft's [Artifact Signing FAQ](https://learn.microsoft.com/azure/artifact-signing/faq) identifies the **Artifact Signing Certificate Profile Signer** role for retrieving a Private Trust profile root. The command follows Microsoft's preview [`Get-AzArtifactSigningCertificateRoot`](https://learn.microsoft.com/powershell/module/az.artifactsigning/get-azartifactsigningcertificateroot) operation and therefore uses a separate preview API-version default. Override **`--api-version`** only when the service contract you target requires it. + +For credential safety, **`--endpoint`** must be an HTTPS origin below **`codesigning.azure.net`** or **`artifactsigning.azure.net`**, with no path, query, fragment, user information, or non-default port. psign validates the endpoint before acquiring a credential and rejects successful responses that are not valid certificate DER. Treat the downloaded certificate as a trust anchor only after the profile identity and execution context have been authenticated as intended. + ## Pure REST portable signing (no Microsoft client tools) For PE/WinMD, prefer the first-class portable signer instead of manually staging a digest: diff --git a/docs/psa-interoperability.md b/docs/psa-interoperability.md index e6c9682..3dd804c 100644 --- a/docs/psa-interoperability.md +++ b/docs/psa-interoperability.md @@ -18,6 +18,7 @@ This note maps PSA behaviors to this repo; see also [`plan-openauthenticode-pari | PSA | psign | |-----|-------------| | **Azure Trusted Signing** via **`Azure.CodeSigning.Sdk`** REST | **`artifact-signing-submit`** (with **`--features artifact-signing-rest`**) — same data-plane **`CertificateProfileOperations_Sign`** LRO as swagger **`2023-06-15-preview`**; **plus** existing **`--dlib`** / **`--trusted-signing-dlib-root`** decoupled path | +| Retrieve the current Artifact Signing profile root | **`artifact-signing-root`** (with **`--features artifact-signing-rest`**) — authenticated preview operation; validates the bounded response as an X.509 DER CA certificate before writing it | | **Azure Key Vault** | **`--azure-key-vault-url`** path (**`--features azure-kv-sign`**) — RSA **and EC** leaf certs (`RS256`/`ES256`-style JWA algorithms) | | Select Trusted Signing profile leaf by EKU prefix **`1.3.6.1.4.1.311.97.`** | **`--signing-cert-eku-prefix`** when selecting from a certificate store | diff --git a/docs/psign-cli-matrix.json b/docs/psign-cli-matrix.json index 4b9ef9d..b61211a 100644 --- a/docs/psign-cli-matrix.json +++ b/docs/psign-cli-matrix.json @@ -303,6 +303,7 @@ {"name": "inspect-authenticode", "maps_to_native_concept": "Portable PKCS#7 inspection JSON (PE or raw)"}, {"name": "artifact-signing-metadata-check", "maps_to_native_concept": "Validate --dmdf-style JSON shape (no network)"}, {"name": "artifact-signing-submit", "maps_to_native_concept": "Trusted Signing :sign LRO (feature artifact-signing-rest); hash file in, JSON out; PE/WinMD embedding is handled by sign-pe --artifact-signing-*"}, + {"name": "artifact-signing-root", "maps_to_native_concept": "Retrieve and validate the current Artifact Signing profile root CA certificate (feature artifact-signing-rest; preview profile-root API; bounded response)"}, {"name": "azure-key-vault-sign-digest", "maps_to_native_concept": "KV keys/sign on digest file (feature azure-kv-sign-portable); AzureSignTool remote step analogue"}, {"name": "nupkg-signature-info", "maps_to_native_concept": "NuGet package-signature marker inspection for `.signature.p7s` (not SIP; groundwork for dotnet nuget sign-compatible portable signing)"}, {"name": "nupkg-digest", "maps_to_native_concept": "Unsigned NuGet package byte hash used by the package-signature properties document (SHA-256/384/512; rejects already signed packages)"},