From f0f078a4edc4c5db9fad8fc8c9b841f68f0e5f95 Mon Sep 17 00:00:00 2001 From: Bryan De Houwer Date: Thu, 10 Sep 2026 02:00:51 +0200 Subject: [PATCH] feat(artifact-signing): retrieve authenticated profile root Add an artifact-signing-root portable command backed by the Artifact Signing REST client. Reuse the existing credential modes, constrain and validate the service endpoint before acquiring credentials, bound response bodies, and require the successful response to contain a DER-encoded CA certificate before writing it. Include focused HTTP, validation, CLI, and documentation coverage. --- Cargo.lock | 3 + crates/psign-codesigning-rest/Cargo.toml | 3 + crates/psign-codesigning-rest/src/lib.rs | 372 +++++++++++++++++++++-- crates/psign-digest-cli/src/main.rs | 104 +++++-- docs/migration-artifact-signing.md | 17 ++ docs/psa-interoperability.md | 1 + docs/psign-cli-matrix.json | 1 + 7 files changed, 451 insertions(+), 50 deletions(-) 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)"},