diff --git a/crates/psign-authenticode-trust/src/rfc3161_extract.rs b/crates/psign-authenticode-trust/src/rfc3161_extract.rs index c94d962..9f49285 100644 --- a/crates/psign-authenticode-trust/src/rfc3161_extract.rs +++ b/crates/psign-authenticode-trust/src/rfc3161_extract.rs @@ -184,7 +184,7 @@ fn timestamp_token_from_signer_unsigned_attrs( fn timestamp_token_from_attribute(attr: &Attribute) -> Option<(ContentInfo, Vec, UtcDate)> { for val in attr.values.iter() { - let token = decode_content_info_loose(attribute_value_bytes(val))?; + let token = timestamp_content_info_from_attribute_value(val)?; if token.content_type != ID_SIGNED_DATA { continue; } @@ -200,6 +200,14 @@ fn timestamp_token_from_attribute(attr: &Attribute) -> Option<(ContentInfo, Vec< None } +fn timestamp_content_info_from_attribute_value(val: &der::asn1::Any) -> Option { + val.to_der() + .ok() + .as_deref() + .and_then(decode_content_info_loose) + .or_else(|| decode_content_info_loose(attribute_value_bytes(val))) +} + fn digest_for_oid(oid: &str, data: &[u8]) -> Result> { Ok(match oid { "1.3.14.3.2.26" => sha1::Sha1::digest(data).to_vec(), diff --git a/crates/psign-codesigning-rest/src/lib.rs b/crates/psign-codesigning-rest/src/lib.rs index 6c29980..c0f8d46 100644 --- a/crates/psign-codesigning-rest/src/lib.rs +++ b/crates/psign-codesigning-rest/src/lib.rs @@ -87,12 +87,15 @@ fn acquire_codesigning_token(params: &CodesigningSubmitParams) -> Result Ok(t.to_string()) } CodesigningAuth::ManagedIdentity => { + let endpoint = std::env::var("PSIGN_CODESIGNING_IMDS_ENDPOINT").unwrap_or_else(|_| { + "http://169.254.169.254/metadata/identity/oauth2/token".to_string() + }); let http = reqwest::blocking::Client::builder() .timeout(Duration::from_secs(120)) .build() .map_err(|e| anyhow!("HTTP client: {e}"))?; let rsp = http - .get("http://169.254.169.254/metadata/identity/oauth2/token") + .get(endpoint) .query(&[("api-version", "2018-02-01"), ("resource", MI_RESOURCE)]) .header("Metadata", "true") .send() @@ -244,7 +247,6 @@ pub fn submit_codesign_hash_blocking( .map(|s| s.to_string()); let body_bytes = rsp.bytes().context(":sign body")?; - let accept_json: Value = serde_json::from_slice(&body_bytes).unwrap_or(Value::Null); if !status.is_success() { return Err(anyhow!( @@ -253,6 +255,11 @@ pub fn submit_codesign_hash_blocking( String::from_utf8_lossy(&body_bytes) )); } + let accept_json: Value = if body_bytes.is_empty() { + Value::Null + } else { + serde_json::from_slice(&body_bytes).context(":sign JSON")? + }; let poll_url = if let Some(loc) = op_location { operation_location_url(&base, &loc) diff --git a/docs/gap-analysis-signing-platforms.md b/docs/gap-analysis-signing-platforms.md index 15b6eb3..81677b3 100644 --- a/docs/gap-analysis-signing-platforms.md +++ b/docs/gap-analysis-signing-platforms.md @@ -144,9 +144,10 @@ Details: [`migration-azuresigntool.md`](migration-azuresigntool.md). |---------|----------------| | Decoupled sign (`--dlib`, `--trusted-signing-dlib-root`, `--dmdf`) | **`psign-tool`** only | | REST hash signing | **`artifact-signing-submit`** (`--features artifact-signing-rest`) on **`psign-tool`** or **`psign-tool portable`** | +| REST PE / WinMD embedding | **`psign-tool portable sign-pe --artifact-signing-*`** and **`psign-tool --mode portable sign --dmdf ... --artifact-signing-*`** build, remote-sign, optionally timestamp, and embed Authenticode | | Metadata validation without signing | **`psign-tool portable artifact-signing-metadata-check`** | -**Gap:** REST output is **not** wired into a portable Authenticode embedder; docs state MVP is hash signing / diagnostics. [`migration-artifact-signing.md`](migration-artifact-signing.md). +**Gap:** REST output is wired into the portable **PE / WinMD** Authenticode embedder, but MSIX/AppX and other non-PE SIP subjects still require the Windows dlib path or future portable embedders. [`migration-artifact-signing.md`](migration-artifact-signing.md). --- @@ -173,7 +174,7 @@ Portable support is intentionally split by lifecycle stage. This keeps Linux/mac The compatibility rule is: **portable mode may prove digest/CMS consistency and explicit-anchor trust, but it must not silently emulate Windows policy.** When a user asks for a Windows-only lifecycle stage, the CLI should fail with an explicit unsupported/not-implemented message and point to the closest portable helper. -**Remote signing steps:** With **`--features azure-kv-sign-portable`**, **`sign-pe --azure-key-vault-*`** performs full PE Authenticode signing with Key Vault RSA signatures, while **`azure-key-vault-sign-digest`** performs Azure Key Vault **`keys/sign`** on a **raw digest file** for lower-level workflows. **`pe-signer-rs256-prehash`**, **`cab-signer-rs256-prehash`**, **`msi-signer-rs256-prehash`**, and **`catalog-signer-rs256-prehash`** (**`--encoding raw`**) emit the **32-byte** **`RS256`** input over **`SignerInfo.signedAttrs`** (distinct from subject-layout digests and from **`verify-catalog`**’s CTL **`eContent`** / PKCS#9 checks). With **`--features artifact-signing-rest`**, **`artifact-signing-submit`** calls Trusted Signing **`:sign`**. The PE/CAB/MSI CMS core can inject externally produced RSA/SHA-2 signature bytes; CAB/MSI/catalog remote-sign CLI routing, MSIX embedding, and broad native-shaped remote-sign routing remain future portable embedder work. +**Remote signing steps:** With **`--features azure-kv-sign-portable`**, **`sign-pe --azure-key-vault-*`** performs full PE Authenticode signing with Key Vault RSA signatures, while **`azure-key-vault-sign-digest`** performs Azure Key Vault **`keys/sign`** on a **raw digest file** for lower-level workflows. **`pe-signer-rs256-prehash`**, **`cab-signer-rs256-prehash`**, **`msi-signer-rs256-prehash`**, and **`catalog-signer-rs256-prehash`** (**`--encoding raw`**) emit the **32-byte** **`RS256`** input over **`SignerInfo.signedAttrs`** (distinct from subject-layout digests and from **`verify-catalog`**’s CTL **`eContent`** / PKCS#9 checks). With **`--features artifact-signing-rest`**, **`artifact-signing-submit`** calls Trusted Signing **`:sign`**, and **`sign-pe --artifact-signing-*`** / top-level **`--mode portable sign --artifact-signing-*`** use that REST signature to embed PE/WinMD Authenticode. CAB/MSI/catalog remote-sign CLI routing, MSIX embedding, and broader native-shaped remote-sign routing remain future portable embedder work. **RFC 3161 TSA helpers:** **`rfc3161-timestamp-req`** builds **`TimeStampReq`** DER from **`--digest-hex`** / **`--digest-file`** (message-imprint preimage; optional **`--nonce`**, **`--cert-req`**) for **`curl`** / OpenSSL **`ts`** against a timestamp URL. **`rfc3161-timestamp-resp-inspect`** prints **`pki_status`** / **`pki_status_int`** (raw status INTEGER) / **`granted`** / token length, **`time_stamp_token_prefix_hex`** (first **16** octets of the raw **`timeStampToken`** TLV, or **`-`** when absent — handy for **`ContentInfo`** / CMS shape checks), **`status_strings_json`** (**`PKIFreeText`**), **`fail_info_tlv_hex`**, and **`fail_info_flags_json`** (RFC 2510 Appendix A **`PKIFailureInfo`** bit names through **`badPOP`**, then **`bit_N`**; **`null`** when the **`BIT STRING`** body is not decodable). Parseable CMS **`id-ct-TSTInfo`** tokens also surface structural **`tst_info_*`** diagnostics: policy OID, message-imprint digest OID/hash, serial, **`genTime`**, and nonce. Optional **`rfc3161-timestamp-http-post`** (**`--features timestamp-http`**) performs the HTTPS POST without **`curl`**. **`timestamp-pe-rfc3161`** can then attach a raw **`timeStampToken`** or granted **`TimeStampResp`** token to an existing PE Authenticode `SignerInfo` as the Microsoft RFC3161 unsigned attribute. This still does not clone every **`SignerTimeStampEx3`** policy branch or timestamp non-PE subjects. diff --git a/src/bin/psign-server.rs b/src/bin/psign-server.rs index ed86e55..3b9c2f0 100644 --- a/src/bin/psign-server.rs +++ b/src/bin/psign-server.rs @@ -148,6 +148,15 @@ struct ArtifactSigningServerArgs { /// Deterministic response variant for positive and negative-path tests. #[arg(long, value_enum, default_value_t = ArtifactResponseMode::Valid)] response_mode: ArtifactResponseMode, + /// Require this signatureAlgorithm value in Artifact Signing submit JSON. + #[arg(long)] + expect_signature_algorithm: Option, + /// Require this api-version query value on Artifact Signing submit requests. + #[arg(long)] + expect_api_version: Option, + /// Require this correlation ID in both Artifact Signing correlation headers. + #[arg(long)] + expect_correlation_id: Option, /// Write the generated root certificate as DER for local trust-anchor setup. #[arg(long, value_name = "PATH")] root_cert_output: Option, @@ -177,14 +186,26 @@ enum PkiOcspStatus { enum ArtifactResponseMode { /// Return a normal accepted operation followed by a Succeeded poll body. Valid, + /// Return HTTP 200 with a Succeeded body from the submit request. + SyncSuccess, + /// Return HTTP 200 with an id field, then require polling through the id fallback URL. + BodyId, + /// Return HTTP 200 with an operationId field, then require polling through the id fallback URL. + BodyOperationId, + /// Return a normal success whose signingCertificate field is a PEM leaf+root bundle. + PemChain, /// Return HTTP 500 for the submit request. HttpError, + /// Return HTTP 503 for the poll request. + PollHttpError, /// Return an accepted operation whose poll body is Failed. Failed, /// Return an accepted operation whose poll body is Canceled. Canceled, /// Return HTTP 200 with malformed JSON for the submit request. MalformedJson, + /// Return HTTP 200 with malformed JSON for the poll request. + PollMalformedJson, } impl ServerStatus { @@ -495,6 +516,9 @@ struct AzureKeyVaultAuthority { struct ArtifactSigningAuthority { identity: AzureSigningIdentity, response_mode: ArtifactResponseMode, + expect_signature_algorithm: Option, + expect_api_version: Option, + expect_correlation_id: Option, base_url: String, next_operation: AtomicU64, operations: Mutex>, @@ -725,6 +749,17 @@ impl AzureSigningIdentity { .context("encode Azure test leaf certificate") } + fn leaf_root_pem_bundle(&self) -> Result> { + let mut pem = der_cert_to_pem(&self.leaf_der()?); + pem.push_str(&der_cert_to_pem( + &self + .root_cert + .to_der() + .context("encode Azure test root certificate")?, + )); + Ok(pem.into_bytes()) + } + fn sign_digest(&self, alg: &str, digest: &[u8]) -> Result> { match alg.trim() { "RS256" => { @@ -765,6 +800,17 @@ impl AzureSigningIdentity { } } +fn der_cert_to_pem(der: &[u8]) -> String { + let b64 = base64::engine::general_purpose::STANDARD.encode(der); + let mut pem = String::from("-----BEGIN CERTIFICATE-----\n"); + for chunk in b64.as_bytes().chunks(64) { + pem.push_str(std::str::from_utf8(chunk).expect("base64 is UTF-8")); + pem.push('\n'); + } + pem.push_str("-----END CERTIFICATE-----\n"); + pem +} + fn write_generated_azure_certs( identity: &AzureSigningIdentity, root_output: &Option, @@ -855,6 +901,9 @@ fn run_artifact_signing_server(args: ArtifactSigningServerArgs) -> Result<()> { 31, )?, response_mode: args.response_mode, + expect_signature_algorithm: args.expect_signature_algorithm, + expect_api_version: args.expect_api_version, + expect_correlation_id: args.expect_correlation_id, base_url: base_url.clone(), next_operation: AtomicU64::new(1), operations: Mutex::new(HashMap::new()), @@ -961,6 +1010,32 @@ fn handle_artifact_signing_client( .set_read_timeout(Some(Duration::from_secs(10))) .context("set read timeout")?; let request = read_http_request(&mut stream)?; + let path = path_without_query(&request.path); + if request.method == "GET" && path == "/metadata/identity/oauth2/token" { + return write_json_response( + &mut stream, + 200, + "OK", + &serde_json::json!({ + "access_token": "psign-server-managed-identity-token", + "expires_in": "3600", + "resource": "https://codesigning.azure.net", + "token_type": "Bearer" + }), + ); + } + if request.method == "POST" && path.ends_with("/oauth2/v2.0/token") { + return write_json_response( + &mut stream, + 200, + "OK", + &serde_json::json!({ + "access_token": "psign-server-client-credentials-token", + "expires_in": 3600, + "token_type": "Bearer" + }), + ); + } if !has_bearer_token(&request) { return write_json_response( &mut stream, @@ -970,9 +1045,57 @@ fn handle_artifact_signing_client( ); } - let path = path_without_query(&request.path); if request.method == "GET" { if let Some(id) = path.strip_prefix("/operations/") { + match authority.response_mode { + ArtifactResponseMode::PollHttpError => { + return write_json_response( + &mut stream, + 503, + "Service Unavailable", + &serde_json::json!({"error":{"code":"InjectedPollFailure"}}), + ); + } + ArtifactResponseMode::PollMalformedJson => { + return write_http_response( + &mut stream, + 200, + "OK", + "application/json", + b"{not-json", + ); + } + _ => {} + } + let operations = authority + .operations + .lock() + .map_err(|_| anyhow!("artifact signing operation store poisoned"))?; + if let Some(body) = operations.get(id) { + return write_json_response(&mut stream, 200, "OK", body); + } + } + if let Some(id) = parse_artifact_poll_path(path) { + match authority.response_mode { + ArtifactResponseMode::PollHttpError => { + return write_json_response( + &mut stream, + 503, + "Service Unavailable", + &serde_json::json!({"error":{"code":"InjectedPollFailure"}}), + ); + } + ArtifactResponseMode::PollMalformedJson => { + return write_http_response( + &mut stream, + 200, + "OK", + "application/json", + b"{not-json", + ); + } + _ => {} + } let operations = authority .operations .lock() @@ -1010,8 +1133,39 @@ fn handle_artifact_signing_client( return write_http_response(&mut stream, 200, "OK", "application/json", b"{not-json"); } ArtifactResponseMode::Valid + | ArtifactResponseMode::SyncSuccess + | ArtifactResponseMode::BodyId + | ArtifactResponseMode::BodyOperationId + | ArtifactResponseMode::PemChain + | ArtifactResponseMode::PollHttpError | ArtifactResponseMode::Failed - | ArtifactResponseMode::Canceled => {} + | ArtifactResponseMode::Canceled + | ArtifactResponseMode::PollMalformedJson => {} + } + + if let Some(expected) = authority.expect_api_version.as_deref() { + let actual = query_param(&request.path, "api-version").unwrap_or_default(); + if actual != expected.trim() { + return write_json_response( + &mut stream, + 400, + "Bad Request", + &serde_json::json!({"error":{"code":"UnexpectedApiVersion","expected":expected,"actual":actual}}), + ); + } + } + if let Some(expected) = authority.expect_correlation_id.as_deref() { + let expected = expected.trim(); + let stable = request.header("x-correlation-id").unwrap_or_default(); + let azure = request.header("x-ms-correlation-id").unwrap_or_default(); + if stable != expected || azure != expected { + return write_json_response( + &mut stream, + 400, + "Bad Request", + &serde_json::json!({"error":{"code":"UnexpectedCorrelationId","expected":expected,"x-correlation-id":stable,"x-ms-correlation-id":azure}}), + ); + } } let body: Value = @@ -1020,6 +1174,16 @@ fn handle_artifact_signing_client( .get("signatureAlgorithm") .and_then(Value::as_str) .ok_or_else(|| anyhow!("Artifact Signing submit body missing signatureAlgorithm"))?; + if let Some(expected) = authority.expect_signature_algorithm.as_deref() { + if alg != expected.trim() { + return write_json_response( + &mut stream, + 400, + "Bad Request", + &serde_json::json!({"error":{"code":"UnexpectedSignatureAlgorithm","expected":expected,"actual":alg}}), + ); + } + } let digest_b64 = body .get("digest") .and_then(Value::as_str) @@ -1033,14 +1197,26 @@ fn handle_artifact_signing_client( authority.next_operation.fetch_add(1, Ordering::Relaxed) ); let operation_body = match authority.response_mode { - ArtifactResponseMode::Valid => { + ArtifactResponseMode::Valid + | ArtifactResponseMode::SyncSuccess + | ArtifactResponseMode::BodyId + | ArtifactResponseMode::BodyOperationId + | ArtifactResponseMode::PemChain + | ArtifactResponseMode::PollHttpError + | ArtifactResponseMode::PollMalformedJson => { let signature = authority.identity.sign_digest(alg, &digest)?; + let cert_payload = if matches!(authority.response_mode, ArtifactResponseMode::PemChain) + { + authority.identity.leaf_root_pem_bundle()? + } else { + authority.identity.leaf_der()? + }; serde_json::json!({ "id": operation_id, "status": "Succeeded", "result": { "signature": base64::engine::general_purpose::STANDARD.encode(signature), - "signingCertificate": base64::engine::general_purpose::STANDARD.encode(authority.identity.leaf_der()?), + "signingCertificate": base64::engine::general_purpose::STANDARD.encode(cert_payload), "codeSigningAccountName": account, "certificateProfileName": profile, } @@ -1057,12 +1233,33 @@ fn handle_artifact_signing_client( }), ArtifactResponseMode::HttpError | ArtifactResponseMode::MalformedJson => unreachable!(), }; + if matches!(authority.response_mode, ArtifactResponseMode::SyncSuccess) { + return write_json_response(&mut stream, 200, "OK", &operation_body); + } authority .operations .lock() .map_err(|_| anyhow!("artifact signing operation store poisoned"))? .insert(operation_id.clone(), operation_body); + if matches!( + authority.response_mode, + ArtifactResponseMode::BodyId | ArtifactResponseMode::BodyOperationId + ) { + let accept_body = if matches!(authority.response_mode, ArtifactResponseMode::BodyId) { + serde_json::json!({ + "id": operation_id, + "status": "InProgress" + }) + } else { + serde_json::json!({ + "operationId": operation_id, + "status": "InProgress" + }) + }; + return write_json_response(&mut stream, 200, "OK", &accept_body); + } + let operation_location = format!("{}operations/{operation_id}", authority.base_url); write_http_response_with_headers( &mut stream, @@ -1078,6 +1275,17 @@ fn path_without_query(path: &str) -> &str { path.split_once('?').map(|(p, _)| p).unwrap_or(path) } +fn query_param(path: &str, name: &str) -> Option { + let query = path.split_once('?')?.1; + for pair in query.split('&') { + let (key, value) = pair.split_once('=').unwrap_or((pair, "")); + if key == name { + return Some(value.to_string()); + } + } + None +} + fn has_bearer_token(request: &HttpRequest) -> bool { request .header("authorization") @@ -1100,6 +1308,24 @@ fn parse_artifact_sign_path(path: &str) -> Option<(&str, &str)> { Some((account, profile)) } +fn parse_artifact_poll_path(path: &str) -> Option<&str> { + let mut segments = path.trim_matches('/').split('/'); + let first = segments.next()?; + let _account = segments.next()?; + let third = segments.next()?; + let _profile = segments.next()?; + let sign = segments.next()?; + let id = segments.next()?; + if segments.next().is_some() + || first != "codesigningaccounts" + || third != "certificateprofiles" + || sign != "sign" + { + return None; + } + Some(id) +} + fn write_json_response( stream: &mut TcpStream, status: u16, diff --git a/tests/cli_pe_digest.rs b/tests/cli_pe_digest.rs index 32bc6c3..81ee59d 100644 --- a/tests/cli_pe_digest.rs +++ b/tests/cli_pe_digest.rs @@ -1058,6 +1058,10 @@ fn tiny32_unsigned_fixture() -> PathBuf { repo_root().join("tests/fixtures/pe-authenticode-upstream/tiny32.efi") } +fn tiny32_unsigned_winmd_fixture() -> PathBuf { + repo_root().join("tests/fixtures/generated-unsigned/winmd/tiny32-pe-copy.winmd") +} + fn tiny64_fixture() -> PathBuf { repo_root().join("tests/fixtures/pe-authenticode-upstream/tiny64.signed.efi") } @@ -3171,6 +3175,162 @@ fn psign_server_artifact_signing_submit_serves_portable_cli() { assert_eq!(sig.len(), 256, "RSA-2048 signature length"); } +#[cfg(all(feature = "timestamp-server", feature = "artifact-signing-rest"))] +#[test] +fn psign_server_artifact_signing_submit_forwards_options_and_uses_body_id_polling() { + let dir = tempfile::tempdir().unwrap(); + let digest_path = dir.path().join("digest.bin"); + std::fs::write(&digest_path, [0xabu8; 48]).expect("write digest"); + + let (mut guard, endpoint) = spawn_psign_artifact_signing_server_with_args( + 2, + &[ + "--response-mode", + "body-id", + "--expect-signature-algorithm", + "RS384", + "--expect-api-version", + "2024-06-15-test", + "--expect-correlation-id", + "trace-abc", + ], + ); + let mut cmd = portable_cmd(); + cmd.arg("artifact-signing-submit") + .arg("--region") + .arg("local") + .arg("--account-name") + .arg("acct") + .arg("--profile-name") + .arg("prof") + .arg("--digest-file") + .arg(&digest_path) + .arg("--signature-algorithm") + .arg("RS384") + .arg("--api-version") + .arg("2024-06-15-test") + .arg("--correlation-id") + .arg("trace-abc") + .arg("--access-token") + .arg("test-token") + .arg("--endpoint-base-url") + .arg(&endpoint); + let output = cmd.output().expect("run artifact-signing-submit"); + assert!( + output.status.success(), + "stderr={}", + String::from_utf8_lossy(&output.stderr) + ); + let status = guard.0.wait().expect("server exit"); + assert!(status.success(), "server failed with {status}"); + let json: Value = serde_json::from_slice(&output.stdout).expect("parse submit JSON"); + assert_eq!( + json.get("status").and_then(Value::as_str), + Some("Succeeded") + ); +} + +#[cfg(all(feature = "timestamp-server", feature = "artifact-signing-rest"))] +#[test] +fn psign_server_artifact_signing_submit_uses_client_credentials() { + let dir = tempfile::tempdir().unwrap(); + let digest_path = dir.path().join("digest.bin"); + std::fs::write(&digest_path, [0x22u8; 32]).expect("write digest"); + + let (mut guard, endpoint) = spawn_psign_artifact_signing_server(3); + let mut cmd = portable_cmd(); + cmd.arg("artifact-signing-submit") + .arg("--region") + .arg("local") + .arg("--account-name") + .arg("acct") + .arg("--profile-name") + .arg("prof") + .arg("--digest-file") + .arg(&digest_path) + .arg("--tenant-id") + .arg("tenant") + .arg("--client-id") + .arg("client") + .arg("--client-secret") + .arg("secret") + .arg("--authority") + .arg(endpoint.trim_end_matches('/')) + .arg("--endpoint-base-url") + .arg(&endpoint); + cmd.assert().success(); + let status = guard.0.wait().expect("server exit"); + assert!(status.success(), "server failed with {status}"); +} + +#[cfg(all(feature = "timestamp-server", feature = "artifact-signing-rest"))] +#[test] +fn psign_server_artifact_signing_submit_uses_managed_identity() { + let dir = tempfile::tempdir().unwrap(); + let digest_path = dir.path().join("digest.bin"); + std::fs::write(&digest_path, [0x33u8; 32]).expect("write digest"); + + let (mut guard, endpoint) = spawn_psign_artifact_signing_server(3); + let mut cmd = portable_cmd(); + cmd.env( + "PSIGN_CODESIGNING_IMDS_ENDPOINT", + format!("{endpoint}metadata/identity/oauth2/token"), + ) + .arg("artifact-signing-submit") + .arg("--region") + .arg("local") + .arg("--account-name") + .arg("acct") + .arg("--profile-name") + .arg("prof") + .arg("--digest-file") + .arg(&digest_path) + .arg("--managed-identity") + .arg("--endpoint-base-url") + .arg(&endpoint); + cmd.assert().success(); + let status = guard.0.wait().expect("server exit"); + assert!(status.success(), "server failed with {status}"); +} + +#[cfg(all(feature = "timestamp-server", feature = "artifact-signing-rest"))] +#[test] +fn psign_server_artifact_signing_submit_surfaces_non_success_shapes() { + let cases = [ + ("http-error", 1, "sign HTTP 500"), + ("malformed-json", 1, "sign JSON"), + ("canceled", 2, "codesign operation canceled"), + ("poll-http-error", 2, "poll HTTP 503"), + ("poll-malformed-json", 2, "poll JSON"), + ]; + for (mode, max_requests, stderr) in cases { + let dir = tempfile::tempdir().unwrap(); + let digest_path = dir.path().join("digest.bin"); + std::fs::write(&digest_path, [0x44u8; 32]).expect("write digest"); + let (mut guard, endpoint) = + spawn_psign_artifact_signing_server_with_args(max_requests, &["--response-mode", mode]); + let mut cmd = portable_cmd(); + cmd.arg("artifact-signing-submit") + .arg("--region") + .arg("local") + .arg("--account-name") + .arg("acct") + .arg("--profile-name") + .arg("prof") + .arg("--digest-file") + .arg(&digest_path) + .arg("--access-token") + .arg("test-token") + .arg("--endpoint-base-url") + .arg(&endpoint); + cmd.assert() + .failure() + .stderr(predicate::str::contains(stderr)); + let status = guard.0.wait().expect("server exit"); + assert!(status.success(), "server failed with {status}"); + } +} + #[cfg(all(feature = "timestamp-server", feature = "artifact-signing-rest"))] #[test] fn psign_server_artifact_signing_signs_pe_with_portable_cli() { @@ -3211,6 +3371,58 @@ fn psign_server_artifact_signing_signs_pe_with_portable_cli() { .expect("portable Artifact Signing Authenticode RSA signature verifies"); } +#[cfg(all(feature = "timestamp-server", feature = "artifact-signing-rest"))] +#[test] +fn psign_server_artifact_signing_signs_pe_with_pem_chain_and_trust_verifies() { + let dir = tempfile::tempdir().unwrap(); + let out_pe = dir.path().join("tiny32.artifact-pem-chain-signed.exe"); + let root_path = dir.path().join("artifact-root.der"); + let root_arg = root_path.to_str().unwrap(); + + let (mut guard, endpoint) = spawn_psign_artifact_signing_server_with_args( + 2, + &[ + "--response-mode", + "pem-chain", + "--root-cert-output", + root_arg, + ], + ); + let mut cmd = portable_cmd(); + cmd.arg("sign-pe") + .arg(tiny32_unsigned_fixture()) + .arg("--artifact-signing-account-name") + .arg("acct") + .arg("--artifact-signing-profile-name") + .arg("prof") + .arg("--artifact-signing-access-token") + .arg("test-token") + .arg("--artifact-signing-endpoint-base-url") + .arg(&endpoint) + .arg("--output") + .arg(&out_pe); + cmd.assert() + .success() + .stdout(predicate::str::contains("sign-pe: ok")); + let status = guard.0.wait().expect("server exit"); + assert!(status.success(), "server failed with {status}"); + + let mut verify = portable_cmd(); + verify.arg("verify-pe").arg(&out_pe); + verify.assert().success(); + + let mut trust = portable_cmd(); + trust + .arg("trust-verify-pe") + .arg("--trusted-ca") + .arg(&root_path) + .arg(&out_pe); + trust + .assert() + .success() + .stdout(predicate::str::contains("trust-verify-pe: ok")); +} + #[cfg(all( feature = "timestamp-server", feature = "timestamp-http", @@ -3220,9 +3432,18 @@ fn psign_server_artifact_signing_signs_pe_with_portable_cli() { fn psign_server_artifact_signing_signs_and_timestamps_pe_with_portable_cli() { let dir = tempfile::tempdir().unwrap(); let out_pe = dir.path().join("tiny32.artifact-portable-timestamped.exe"); + let artifact_root_path = dir.path().join("artifact-root.der"); + let tsa_root_path = dir.path().join("tsa-root.der"); + let artifact_root_arg = artifact_root_path.to_str().unwrap(); + let tsa_root_arg = tsa_root_path.to_str().unwrap(); - let (mut guard, endpoint) = spawn_psign_artifact_signing_server(2); - let (mut timestamp_guard, timestamp_url) = spawn_psign_server(&[]); + let (mut guard, endpoint) = spawn_psign_artifact_signing_server_with_args( + 2, + &["--root-cert-output", artifact_root_arg], + ); + let gen_time = generalized_time_tomorrow_noon_utc(); + let (mut timestamp_guard, timestamp_url) = + spawn_psign_server_with_gen_time(&gen_time, &["--cert-output", tsa_root_arg]); let mut cmd = portable_cmd(); cmd.arg("sign-pe") .arg(tiny32_unsigned_fixture()) @@ -3264,6 +3485,87 @@ fn psign_server_artifact_signing_signs_and_timestamps_pe_with_portable_cli() { "microsoft_nested_rfc3161_attribute", )) .stdout(predicate::str::contains("1.3.6.1.4.1.311.3.3.1")); + + let mut trust = portable_cmd(); + trust + .arg("trust-verify-pe") + .arg("--trusted-ca") + .arg(&artifact_root_path) + .arg("--trusted-ca") + .arg(&tsa_root_path) + .arg("--prefer-timestamp-signing-time") + .arg("--require-valid-timestamp") + .arg(&out_pe); + trust + .assert() + .success() + .stdout(predicate::str::contains("trust-verify-pe: ok")); +} + +#[cfg(all(feature = "timestamp-server", feature = "artifact-signing-rest"))] +#[test] +fn psign_server_artifact_signing_signs_winmd_with_portable_cli() { + let dir = tempfile::tempdir().unwrap(); + let out_winmd = dir.path().join("tiny32.artifact-signed.winmd"); + let root_path = dir.path().join("artifact-root.der"); + let root_arg = root_path.to_str().unwrap(); + + let (mut guard, endpoint) = + spawn_psign_artifact_signing_server_with_args(2, &["--root-cert-output", root_arg]); + let mut cmd = portable_cmd(); + cmd.arg("sign-pe") + .arg(tiny32_unsigned_winmd_fixture()) + .arg("--artifact-signing-account-name") + .arg("acct") + .arg("--artifact-signing-profile-name") + .arg("prof") + .arg("--artifact-signing-access-token") + .arg("test-token") + .arg("--artifact-signing-endpoint-base-url") + .arg(&endpoint) + .arg("--output") + .arg(&out_winmd); + cmd.assert() + .success() + .stdout(predicate::str::contains("sign-pe: ok")); + let status = guard.0.wait().expect("server exit"); + assert!(status.success(), "server failed with {status}"); + + let mut trust = portable_cmd(); + trust + .arg("trust-verify-pe") + .arg("--trusted-ca") + .arg(&root_path) + .arg(&out_winmd); + trust + .assert() + .success() + .stdout(predicate::str::contains("trust-verify-pe: ok")); +} + +#[cfg(feature = "artifact-signing-rest")] +#[test] +fn mode_portable_artifact_signing_rejects_non_pe_targets_before_remote_submit() { + let dir = tempfile::tempdir().unwrap(); + let cab_path = dir.path().join("unsigned.cab"); + std::fs::write(&cab_path, minimal_unsigned_cab_bytes()).expect("write CAB"); + + let mut cmd = Command::cargo_bin("psign-tool").unwrap(); + cmd.arg("--mode") + .arg("portable") + .arg("sign") + .arg("--digest") + .arg("sha256") + .arg("--artifact-signing-account-name") + .arg("acct") + .arg("--artifact-signing-profile-name") + .arg("prof") + .arg("--artifact-signing-access-token") + .arg("test-token") + .arg(&cab_path); + cmd.assert().failure().stderr(predicate::str::contains( + "portable Artifact Signing is currently implemented only for PE/WinMD targets", + )); } #[cfg(all(