diff --git a/crates/psign-digest-cli/src/main.rs b/crates/psign-digest-cli/src/main.rs index 788a4e9..cd7aaae 100644 --- a/crates/psign-digest-cli/src/main.rs +++ b/crates/psign-digest-cli/src/main.rs @@ -412,6 +412,7 @@ struct MsixManifestInfo { publisher: Option, version: Option, processor_architecture: Option, + package_publisher: Option, } #[derive(Debug, Eq, PartialEq)] @@ -1141,13 +1142,19 @@ fn inspect_msix_manifest_path(path: &Path) -> Result { reject_encrypted_msix_path(path)?; let manifest = read_msix_manifest(path)?; let identity = first_tag(&manifest, "Identity") - .ok_or_else(|| anyhow!("MSIX/AppX AppxManifest.xml is missing Identity"))?; - Ok(MsixManifestInfo { + .ok_or_else(|| anyhow!("MSIX/AppX package manifest is missing Identity"))?; + let mut info = MsixManifestInfo { package_name: xml_attr(identity, "Name"), publisher: xml_attr(identity, "Publisher"), version: xml_attr(identity, "Version"), processor_architecture: xml_attr(identity, "ProcessorArchitecture"), - }) + package_publisher: None, + }; + if let Some(package) = first_start_tag_by_local_name(&manifest, "Package")? { + // Bundle manifests additionally mirror the child package publisher. + info.package_publisher = xml_attr(package, "Publisher"); + } + Ok(info) } fn set_msix_manifest_publisher_path(input: &Path, output: &Path, publisher: &str) -> Result<()> { @@ -1176,16 +1183,25 @@ fn reject_encrypted_msix_path(path: &Path) -> Result<()> { Ok(()) } +/// Read the package manifest, preferring the flat `AppxManifest.xml` and falling back to +/// `AppxMetadata/AppxBundleManifest.xml` for `.msixbundle` / `.appxbundle` inputs. fn read_msix_manifest(path: &Path) -> Result { let file = File::open(path).with_context(|| format!("open {}", path.display()))?; let mut archive = zip::ZipArchive::new(file).context("open MSIX/AppX ZIP")?; - let mut manifest = archive - .by_name("AppxManifest.xml") - .context("read AppxManifest.xml")?; + if let Ok(mut manifest) = archive.by_name("AppxManifest.xml") { + let mut text = String::new(); + manifest + .read_to_string(&mut text) + .context("read AppxManifest.xml as UTF-8")?; + return Ok(text); + } + let mut bundle_manifest = archive + .by_name("AppxMetadata/AppxBundleManifest.xml") + .context("read AppxManifest.xml or AppxMetadata/AppxBundleManifest.xml")?; let mut text = String::new(); - manifest + bundle_manifest .read_to_string(&mut text) - .context("read AppxManifest.xml as UTF-8")?; + .context("read AppxMetadata/AppxBundleManifest.xml as UTF-8")?; Ok(text) } @@ -1201,6 +1217,12 @@ where "MSIX/AppX package already contains AppxSignature.p7x; update the unsigned package before final signing" )); } + let is_bundle = input.by_name("AppxManifest.xml").is_err(); + if is_bundle && input.by_name("AppxMetadata/AppxBundleManifest.xml").is_err() { + return Err(anyhow!( + "MSIX/AppX package is missing AppxManifest.xml (or AppxMetadata/AppxBundleManifest.xml for bundles)" + )); + } let mut output = zip::ZipWriter::new(writer); let mut updated_manifest = false; @@ -1220,13 +1242,24 @@ where let updated = update_attr_for_tags(&text, "Identity", "Publisher", &escaped)?; output.write_all(updated.as_bytes())?; updated_manifest = true; + } else if name == "AppxMetadata/AppxBundleManifest.xml" { + let mut text = String::new(); + file.read_to_string(&mut text) + .context("read AppxMetadata/AppxBundleManifest.xml as UTF-8")?; + // Bundle manifests carry Identity@Publisher plus per-child Package@Publisher mirrors. + let mut updated = update_attr_for_local_tags(&text, "Identity", "Publisher", &escaped)?; + updated = update_attr_for_local_tags(&updated, "Package", "Publisher", &escaped)?; + output.write_all(updated.as_bytes())?; + updated_manifest = true; } else { std::io::copy(&mut file, &mut output)?; } } if !updated_manifest { - return Err(anyhow!("MSIX/AppX package is missing AppxManifest.xml")); + return Err(anyhow!( + "MSIX/AppX package is missing AppxManifest.xml (or AppxMetadata/AppxBundleManifest.xml for bundles)" + )); } output.finish()?; Ok(()) @@ -5019,6 +5052,9 @@ where "processor_architecture={}", info.processor_architecture.unwrap_or("-".to_string()) ); + if let Some(package_publisher) = info.package_publisher { + println!("package_publisher={package_publisher}"); + } } Command::MsixSetPublisher { path, diff --git a/crates/psign-portable-core/src/lib.rs b/crates/psign-portable-core/src/lib.rs index 354cc50..e9f15fd 100644 --- a/crates/psign-portable-core/src/lib.rs +++ b/crates/psign-portable-core/src/lib.rs @@ -43,6 +43,8 @@ pub enum PortableFileFormat { Cab, Msi, Msix, + MsixUpload, + MsixEncrypted, Catalog, Zip, NuGet, @@ -545,10 +547,13 @@ pub fn portable_sign(request: PortableSignRequest) -> Result { - sign_msix(&request, &output_path)?; - false - } + PortableFileFormat::Msix => sign_msix(&request, &output_path)?, + PortableFileFormat::MsixUpload => bail!( + "portable signing does not support MSIX/AppX upload bundles (.appxupload/.msixupload); they are dotnet/SignTool packaging containers whose nested flat packages should be prepared and signed individually" + ), + PortableFileFormat::MsixEncrypted => bail!( + "portable signing does not support encrypted MSIX/AppX packages (.eappx/.eappxbundle/.emsix/.emsixbundle); encrypted packages require Windows AppxSip OS delegation" + ), PortableFileFormat::NuGet => { sign_nuget(&request, &output_path)?; false @@ -610,6 +615,12 @@ pub fn portable_get_signature( PortableFileFormat::Cab => inspect_cab(&request.path), PortableFileFormat::Msi => inspect_msi(&request.path), PortableFileFormat::Msix => inspect_msix(&request.path), + PortableFileFormat::MsixUpload => Err(anyhow::anyhow!( + "MSIX/AppX upload bundles (.appxupload/.msixupload) are dotnet/SignTool packaging containers, not AppX SIP verify subjects" + )), + PortableFileFormat::MsixEncrypted => Err(anyhow::anyhow!( + "encrypted MSIX/AppX packages require Windows AppxSip OS delegation; portable cleartext digest validation does not apply" + )), PortableFileFormat::NuGet => inspect_nuget(&request.path, &data), PortableFileFormat::Vsix => inspect_vsix_opc(&request.path, &data), PortableFileFormat::ClickOnceManifest => inspect_clickonce_manifest(&request.path, &data), @@ -841,6 +852,8 @@ pub fn infer_format(path: &Path) -> PortableFileFormat { "cab" => PortableFileFormat::Cab, "msi" | "msp" => PortableFileFormat::Msi, "msix" | "appx" | "msixbundle" | "appxbundle" => PortableFileFormat::Msix, + "appxupload" | "msixupload" => PortableFileFormat::MsixUpload, + "eappx" | "eappxbundle" | "emsix" | "emsixbundle" => PortableFileFormat::MsixEncrypted, "cat" => PortableFileFormat::Catalog, "nupkg" | "snupkg" => PortableFileFormat::NuGet, "vsix" => PortableFileFormat::Vsix, @@ -1799,20 +1812,52 @@ fn sign_msi(request: &PortableSignRequest, output_path: &Path) -> Result<()> { .with_context(|| format!("embed Authenticode signature in {}", request.path.display())) } -fn sign_msix(request: &PortableSignRequest, output_path: &Path) -> Result<()> { +const MSIX_FLAT_EXTENSIONS: [&str; 2] = ["msix", "appx"]; +const MSIX_BUNDLE_EXTENSIONS: [&str; 2] = ["msixbundle", "appxbundle"]; + +/// Cleartext MSIX family extensions accepted by portable final signing: +/// flat `.msix` / `.appx` packages and `.msixbundle` / `.appxbundle` bundles. +pub fn is_portable_msix_family_extension(ext: &str) -> bool { + MSIX_FLAT_EXTENSIONS.contains(&ext) || MSIX_BUNDLE_EXTENSIONS.contains(&ext) +} + +fn is_msix_bundle_extension(ext: &str) -> bool { + MSIX_BUNDLE_EXTENSIONS.contains(&ext) +} + +fn sign_msix(request: &PortableSignRequest, output_path: &Path) -> Result { let ext = request .path .extension() .and_then(|e| e.to_str()) .unwrap_or("") .to_ascii_lowercase(); - if !matches!(ext.as_str(), "msix" | "appx") { - bail!("portable MSIX signing currently supports flat .msix/.appx packages"); + if msix_digest::is_encrypted_msix_extension(&ext) { + bail!( + "portable MSIX signing does not support encrypted packages (.{ext}); encrypted packages require Windows AppxSip OS delegation" + ); + } + if !is_portable_msix_family_extension(&ext) { + bail!( + "portable MSIX signing supports flat .msix/.appx packages and .msixbundle/.appxbundle bundles; got .{ext}" + ); } let package = std::fs::read(&request.path).with_context(|| format!("read {}", request.path.display()))?; - let staged = stage_flat_msix_for_signature(&package, request.hash_algorithm) + if request.skip_signed && msix_digest::msix_signature_part_present(&package)? { + if output_path != request.path.as_path() { + std::fs::copy(&request.path, output_path).with_context(|| { + format!( + "copy {} to {}", + request.path.display(), + output_path.display() + ) + })?; + } + return Ok(true); + } + let staged = stage_msix_family_for_signature(&package, &ext, request.hash_algorithm) .with_context(|| format!("stage {} for MSIX signing", request.path.display()))?; let provider = load_signing_provider(request)?; let digest_algorithm = request.hash_algorithm.into(); @@ -1831,7 +1876,9 @@ fn sign_msix(request: &PortableSignRequest, output_path: &Path) -> Result<()> { p7x.extend_from_slice(&pkcs7); let signed = replace_msix_signature_part(&staged, &p7x) .with_context(|| format!("embed AppxSignature.p7x in {}", request.path.display()))?; - std::fs::write(output_path, signed).with_context(|| format!("write {}", output_path.display())) + std::fs::write(output_path, signed) + .with_context(|| format!("write {}", output_path.display()))?; + Ok(false) } fn sign_zip(request: &PortableSignRequest, output_path: &Path) -> Result<()> { @@ -2440,6 +2487,9 @@ fn apply_trust_if_requested( PortableFileFormat::NuGet | PortableFileFormat::AppInstaller | PortableFileFormat::Vsix + | PortableFileFormat::Msix + | PortableFileFormat::MsixUpload + | PortableFileFormat::MsixEncrypted | PortableFileFormat::ClickOnceManifest => Err(anyhow::anyhow!( "explicit trust verification is not yet available for format {:?} through the portable inspection path", format @@ -3113,6 +3163,18 @@ fn looks_unsigned(message: &str) -> bool { || lower.contains("signature block") } +fn stage_msix_family_for_signature( + package: &[u8], + ext: &str, + digest_algorithm: PortableDigestAlgorithm, +) -> Result> { + if is_msix_bundle_extension(ext) { + stage_msix_bundle_for_signature(package, digest_algorithm) + } else { + stage_flat_msix_for_signature(package, digest_algorithm) + } +} + fn stage_flat_msix_for_signature( package: &[u8], digest_algorithm: PortableDigestAlgorithm, @@ -3174,6 +3236,124 @@ fn stage_flat_msix_for_signature( Ok(out.into_inner()) } +/// Stage a `.msixbundle` / `.appxbundle` for final AppX SIP signing. +/// +/// Native `AppxBundleSip` semantics: the block map covers **only** the bundle manifest +/// (`AppxMetadata/AppxBundleManifest.xml`, listed with backslash separators and a per-block +/// `Size`), child packages stay byte-identical ZIP payload entries, and the signature part is +/// excluded from all AXPC/AXCD digest pieces. Staged output: child payloads + bundle manifest, +/// then `AppxBlockMap.xml`, `[Content_Types].xml`, and a `PKCX` placeholder signature part. +fn stage_msix_bundle_for_signature( + package: &[u8], + digest_algorithm: PortableDigestAlgorithm, +) -> Result> { + let mut source = ZipArchive::new(Cursor::new(package)).context("open MSIX bundle ZIP")?; + let mut child_payloads: Vec<(String, Vec)> = Vec::new(); + let mut bundle_manifest: Option> = None; + let mut bundle_content_types: Option> = None; + + for i in 0..source.len() { + let mut entry = source.by_index(i).context("read MSIX bundle ZIP entry")?; + let name = entry.name().replace('\\', "/"); + if name.ends_with('/') { + continue; + } + match name.as_str() { + "AppxMetadata/AppxBundleManifest.xml" => { + let mut data = Vec::new(); + entry.read_to_end(&mut data)?; + bundle_manifest = Some(data); + } + "[Content_Types].xml" => { + let mut data = Vec::new(); + entry.read_to_end(&mut data)?; + bundle_content_types = Some(data); + } + "AppxBlockMap.xml" + | "AppxSignature.p7x" + | "AppxManifest.xml" + | "AppxMetadata/CodeIntegrity.cat" => {} + _ => { + let mut data = Vec::new(); + entry.read_to_end(&mut data)?; + child_payloads.push((name, data)); + } + } + } + + let manifest = bundle_manifest.ok_or_else(|| { + anyhow::anyhow!("MSIX bundle is missing AppxMetadata/AppxBundleManifest.xml") + })?; + let content_types = bundle_content_types + .ok_or_else(|| anyhow::anyhow!("MSIX bundle is missing [Content_Types].xml"))?; + let content_types = add_msix_signature_content_type( + std::str::from_utf8(&content_types).context("[Content_Types].xml is not UTF-8")?, + )?; + for (name, _data) in &child_payloads { + let child_ext = name + .rsplit_once('.') + .map(|(_, ext)| ext.to_ascii_lowercase()) + .unwrap_or_default(); + if msix_digest::is_encrypted_msix_extension(&child_ext) { + bail!( + "cleartext MSIX bundle contains encrypted child package `{name}`; encrypted children require Windows AppxSip OS delegation" + ); + } + } + let block_map = build_msix_bundle_block_map(&manifest, digest_algorithm)?; + + let mut out = Cursor::new(Vec::new()); + { + let mut writer = ZipWriter::new(&mut out); + let stored = FileOptions::default().compression_method(CompressionMethod::Stored); + for (name, data) in &child_payloads { + writer.start_file(name, stored)?; + writer.write_all(data)?; + } + writer.start_file("AppxMetadata/AppxBundleManifest.xml", stored)?; + writer.write_all(&manifest)?; + writer.start_file("AppxBlockMap.xml", stored)?; + writer.write_all(block_map.as_bytes())?; + writer.start_file("[Content_Types].xml", stored)?; + writer.write_all(content_types.as_bytes())?; + writer.start_file("AppxSignature.p7x", stored)?; + writer.write_all(b"PKCX")?; + writer.finish()?; + } + Ok(out.into_inner()) +} + +/// Native bundle block maps list exactly one `File` — the bundle manifest — using +/// backslash separators, the manifest's uncompressed size, and a per-block `Size`. +fn build_msix_bundle_block_map( + manifest: &[u8], + digest_algorithm: PortableDigestAlgorithm, +) -> Result { + let hash_method = match digest_algorithm { + PortableDigestAlgorithm::Sha256 => "http://www.w3.org/2001/04/xmlenc#sha256", + PortableDigestAlgorithm::Sha384 => "http://www.w3.org/2004/xmldsig-more#sha384", + PortableDigestAlgorithm::Sha512 => "http://www.w3.org/2001/04/xmlenc#sha512", + }; + let mut xml = format!( + r#""#, + manifest.len() + ); + for chunk in manifest.chunks(64 * 1024) { + let digest = match digest_algorithm { + PortableDigestAlgorithm::Sha256 => sha2::Sha256::digest(chunk).to_vec(), + PortableDigestAlgorithm::Sha384 => sha2::Sha384::digest(chunk).to_vec(), + PortableDigestAlgorithm::Sha512 => sha2::Sha512::digest(chunk).to_vec(), + }; + let encoded = base64::engine::general_purpose::STANDARD.encode(digest); + xml.push_str(&format!( + r#""#, + chunk.len() + )); + } + xml.push_str(""); + Ok(xml) +} + fn replace_msix_signature_part(package: &[u8], p7x: &[u8]) -> Result> { let mut source = ZipArchive::new(Cursor::new(package)).context("open staged MSIX ZIP")?; let mut out = Cursor::new(Vec::new()); @@ -3383,6 +3563,42 @@ mod tests { ); } + #[test] + fn infers_msix_family_formats() { + assert_eq!( + infer_format(Path::new("app.msix")), + PortableFileFormat::Msix + ); + assert_eq!( + infer_format(Path::new("app.appx")), + PortableFileFormat::Msix + ); + assert_eq!( + infer_format(Path::new("bundle.msixbundle")), + PortableFileFormat::Msix + ); + assert_eq!( + infer_format(Path::new("bundle.APPXBUNDLE")), + PortableFileFormat::Msix + ); + assert_eq!( + infer_format(Path::new("upload.msixupload")), + PortableFileFormat::MsixUpload + ); + assert_eq!( + infer_format(Path::new("upload.appxupload")), + PortableFileFormat::MsixUpload + ); + assert_eq!( + infer_format(Path::new("enc.emsix")), + PortableFileFormat::MsixEncrypted + ); + assert_eq!( + infer_format(Path::new("enc.eappxbundle")), + PortableFileFormat::MsixEncrypted + ); + } + #[test] fn appends_script_signature_block_using_source_utf16_encoding() { let block = "\r\n# SIG # Begin signature block\r\n"; @@ -3391,8 +3607,10 @@ mod tests { assert_eq!( String::from_utf16( &signed_le[2..] - .chunks_exact(2) - .map(|bytes| u16::from_le_bytes([bytes[0], bytes[1]])) + .as_chunks::<2>() + .0 + .iter() + .map(|bytes| u16::from_le_bytes(*bytes)) .collect::>() ) .expect("UTF-16LE script"), @@ -3404,8 +3622,10 @@ mod tests { assert_eq!( String::from_utf16( &signed_be[2..] - .chunks_exact(2) - .map(|bytes| u16::from_be_bytes([bytes[0], bytes[1]])) + .as_chunks::<2>() + .0 + .iter() + .map(|bytes| u16::from_be_bytes(*bytes)) .collect::>() ) .expect("UTF-16BE script"), diff --git a/crates/psign-sip-digest/src/catalog_digest.rs b/crates/psign-sip-digest/src/catalog_digest.rs index 12428a3..0265f19 100644 --- a/crates/psign-sip-digest/src/catalog_digest.rs +++ b/crates/psign-sip-digest/src/catalog_digest.rs @@ -499,8 +499,10 @@ fn decode_utf16le_subject_identifier(bytes: &[u8]) -> Option { return None; } let units: Vec = bytes - .chunks_exact(2) - .map(|b| u16::from_le_bytes([b[0], b[1]])) + .as_chunks::<2>() + .0 + .iter() + .map(|b| u16::from_le_bytes(*b)) .take_while(|u| *u != 0) .collect(); String::from_utf16(&units).ok().map(|s| { diff --git a/crates/psign-sip-digest/src/msix_digest.rs b/crates/psign-sip-digest/src/msix_digest.rs index 8d310f9..5a3aa36 100644 --- a/crates/psign-sip-digest/src/msix_digest.rs +++ b/crates/psign-sip-digest/src/msix_digest.rs @@ -794,7 +794,13 @@ fn find_zip64_eocd( fn parse_zip_tail(buf: &[u8]) -> Result { let (cde_pos, classic) = find_classic_eocd(buf)?; - if classic.disk_number != 0 || classic.disk_with_central_directory != 0 { + // ZIP64 archives store 0xFFFF/0xFFFFFFFF sentinels in the classic EOCD; the real + // disk fields live in the ZIP64 EOCD and are validated after it is parsed below. + let classic_disk_fields_are_sentinels = + classic.disk_number == u16::MAX && classic.disk_with_central_directory == u16::MAX; + if !classic_disk_fields_are_sentinels + && (classic.disk_number != 0 || classic.disk_with_central_directory != 0) + { return Err(anyhow!( "multi-disk ZIP archives are not valid APPX/MSIX packages" )); @@ -1881,6 +1887,12 @@ pub fn verify_msix_digest_consistency(path: &Path) -> Result<()> { verify_msix_digest_consistency_bytes(&buf, &ext) } +/// Fast `--skip-signed` preflight: does this OPC/ZIP package already carry an `AppxSignature.p7x` part? +pub fn msix_signature_part_present(package: &[u8]) -> Result { + let archive = ZipArchive::new(Cursor::new(package))?; + Ok(archive.file_names().any(|name| name == "AppxSignature.p7x")) +} + /// Compute the APPX Authenticode `messageDigest` blob for a cleartext MSIX / APPX package. /// /// The input package must already contain `AppxSignature.p7x` and the matching diff --git a/crates/psign-sip-digest/src/ps_script.rs b/crates/psign-sip-digest/src/ps_script.rs index e942c36..bb42096 100644 --- a/crates/psign-sip-digest/src/ps_script.rs +++ b/crates/psign-sip-digest/src/ps_script.rs @@ -76,14 +76,18 @@ fn markers(family: MarkerFamily) -> (Vec, Vec, Vec, Vec) { pub fn file_utf16_units(raw: &[u8]) -> Vec { if raw.len() >= 2 && raw[0] == 0xFF && raw[1] == 0xFE { return raw[2..] - .chunks_exact(2) - .map(|c| u16::from_le_bytes([c[0], c[1]])) + .as_chunks::<2>() + .0 + .iter() + .map(|c| u16::from_le_bytes(*c)) .collect(); } if raw.len() >= 2 && raw[0] == 0xFE && raw[1] == 0xFF { return raw[2..] - .chunks_exact(2) - .map(|c| u16::from_be_bytes([c[0], c[1]])) + .as_chunks::<2>() + .0 + .iter() + .map(|c| u16::from_be_bytes(*c)) .collect(); } let lossy = String::from_utf8_lossy(raw); diff --git a/crates/psign-sip-digest/src/rdp.rs b/crates/psign-sip-digest/src/rdp.rs index 4faa73f..c2fd3bb 100644 --- a/crates/psign-sip-digest/src/rdp.rs +++ b/crates/psign-sip-digest/src/rdp.rs @@ -412,15 +412,19 @@ fn remove_record(records: &mut Vec, name: &str) { fn le_u16s(bytes: &[u8]) -> Vec { bytes - .chunks_exact(2) - .map(|c| u16::from_le_bytes([c[0], c[1]])) + .as_chunks::<2>() + .0 + .iter() + .map(|c| u16::from_le_bytes(*c)) .collect() } fn be_u16s(bytes: &[u8]) -> Vec { bytes - .chunks_exact(2) - .map(|c| u16::from_be_bytes([c[0], c[1]])) + .as_chunks::<2>() + .0 + .iter() + .map(|c| u16::from_be_bytes(*c)) .collect() } diff --git a/docs/gap-analysis-signing-platforms.md b/docs/gap-analysis-signing-platforms.md index 9855a10..07ad17f 100644 --- a/docs/gap-analysis-signing-platforms.md +++ b/docs/gap-analysis-signing-platforms.md @@ -27,7 +27,7 @@ Legend: **Sign** = produce/embed Authenticode; **WT verify** = `WinVerifyTrust`- **AzureSignTool** targets the same **embedding path as SignTool** (Windows): typically PE (and same SIP stack as invoked by `SignerSignEx3`). It does **not** define new subject formats—it replaces the CSP with **KV `keys/sign`**. -**Artifact Signing REST** (`:sign` LRO) returns **signature material** for a **hash**; PE/WinMD, PowerShell Authenticode scripts, CAB, MSI/MSP, flat MSIX/AppX, and generic catalog portable signing now build CMS, ask the service to sign the CMS authenticated-attributes digest, and embed the PKCS#7 without Microsoft client DLLs. The portable Rust credential resolver supports bearer tokens, client-secret credentials, system- and user-assigned managed identity, workload identity federation, and metadata `ExcludeCredentials` for the non-interactive default chain. PE/WinMD, PowerShell scripts, CAB, MSI/MSP, generic catalog, and flat MSIX/AppX Artifact Signing paths support sign-time RFC3161 timestamping when built with `timestamp-http`. MSIX/AppX bundles/uploads, encrypted packages, and other SIP remote-sign embedding still require **Windows `SignerSignEx3` + dlib** or future portable embedders. +**Artifact Signing REST** (`:sign` LRO) returns **signature material** for a **hash**; PE/WinMD, PowerShell Authenticode scripts, CAB, MSI/MSP, flat MSIX/AppX packages, MSIX/AppX bundles, and generic catalog portable signing now build CMS, ask the service to sign the CMS authenticated-attributes digest, and embed the PKCS#7 without Microsoft client DLLs. The portable Rust credential resolver supports bearer tokens, client-secret credentials, system- and user-assigned managed identity, workload identity federation, and metadata `ExcludeCredentials` for the non-interactive default chain. PE/WinMD, PowerShell scripts, CAB, MSI/MSP, generic catalog, and flat MSIX/AppX + bundle Artifact Signing paths support sign-time RFC3161 timestamping when built with `timestamp-http`. Upload containers, encrypted packages, and other SIP remote-sign embedding still require **Windows `SignerSignEx3` + dlib** or future portable embedders. ## Expanded signable-surface audit by mode @@ -42,7 +42,7 @@ This inventory starts from the in-tree supported formats, then expands to inbox | **Catalog** (`.cat`) and driver-package catalogs | Catalog verify paths and `catdb`; can Authenticode-sign an existing `.cat`. | No catalog authoring (`MakeCat`/`Inf2Cat`/`New-FileCatalog` equivalent) or full driver-package workflow. | `sign-catalog` for portable generic CTL catalogs with local RSA or Artifact Signing REST, RFC3161 timestamp embed, `verify-catalog`, `verify-catalog-member` for explicit file + MakeCat/psign catalog inputs, `trust-verify-catalog`, catalog PKCS#7 consistency, signer prehash. | No native-shaped in-place `.cat` Artifact Signing route, `CryptCATAdmin` database search, driver/INF policy, OS catalog stores, catalog-store revocation policy, or MakeCat byte-for-byte output. | | **MSI family** (`.msi`, `.msp`, `.mst`) | Sign/verify through `MSISIP.DLL`. | Generic SIP remove is not implemented; optional parity corpus depends on external fixtures. | `verify-msi`, local RSA or Artifact Signing REST `sign-msi` through the `DigitalSignature` stream, and native-shaped `--mode portable sign` using PFX/certificate-store, Azure Key Vault, or Artifact Signing (where documented), plus PKCS#7 extraction/prehash and RFC3161 timestamp embed. | No `MsiDigitalSignatureEx` authoring or installer policy branches such as `DisableSizeVerification` / `DisableLegacyVerification`. | | **WIM / ESD** (`.wim`, `.esd`) | Sign/verify through `EsdSip.dll`. | Positive parity fixtures are limited; no remove. | `verify-esd`. | No WIM/ESD signing/embed, timestamp embed, or WinTrust policy equivalent. | -| **Cleartext AppX/MSIX** (`.appx`, `.msix`, `.appxbundle`, `.msixbundle`, `.appxupload`, `.msixupload`) | Sign/verify with AppX client data and dlib bridge. | Remaining native parity failures can occur around `SignerSignEx3` AppX glue, publisher binding, sealing, and package constraints. | `verify-msix` digest consistency; `msix-manifest-info` / `msix-set-publisher`; native-shaped portable Artifact Signing final signing for flat `.appx` / `.msix` packages with `AppxSignature.p7x` / `PKCX` embedding and optional RFC3161 timestamping; guarded `psign-tool code` prepare execution signs nested PE/package entries, updates `AppxManifest.xml` Publisher from `--publisher-name`, regenerates `AppxBlockMap.xml`, propagates publisher updates into nested packages inside upload/bundle containers, and rejects already-final-signed `AppxSignature.p7x` packages before final AppX SIP signing. | Bundle/upload final signing, encrypted packages, manifest publisher-vs-signer policy, and full AppX package policy remain pending. | +| **Cleartext AppX/MSIX** (`.appx`, `.msix`, `.appxbundle`, `.msixbundle`, `.appxupload`, `.msixupload`) | Sign/verify with AppX client data and dlib bridge. | Remaining native parity failures can occur around `SignerSignEx3` AppX glue, publisher binding, sealing, and package constraints. | `verify-msix` digest consistency; `msix-manifest-info` / `msix-set-publisher` (flat `AppxManifest.xml` or bundle `AppxMetadata/AppxBundleManifest.xml`, updating Identity and child Package Publisher mirrors); native-shaped portable Artifact Signing / Key Vault final signing for flat `.appx` / `.msix` packages **and `.msixbundle` / `.appxbundle` bundles** (native `AppxBundleSip` semantics: manifest-only block map, `AppxSignature.p7x` / `PKCX` embedding, recursive child verification) with optional RFC3161 timestamping and `--skip-signed` detection; guarded `psign-tool code` prepare execution signs nested PE/package entries, updates `AppxManifest.xml` Publisher from `--publisher-name` (and `AppxBundleManifest.xml` Identity/Package Publisher for bundle layouts), regenerates flat or bundle-shaped `AppxBlockMap.xml`, propagates publisher updates into nested packages inside upload/bundle containers, and rejects already-final-signed `AppxSignature.p7x` packages before final AppX SIP signing. | Upload-container final signing, encrypted packages, manifest publisher-vs-signer policy, and full AppX package policy remain pending. | | **Encrypted AppX/MSIX** (`.eappx`, `.emsix`, `.eappxbundle`, `.emsixbundle`) | Delegates to OS `EappxSip*` / `EappxBundleSip*`. | No in-tree understanding beyond OS delegation and parity fixtures. | Explicitly rejected by `verify-msix`, MSIX metadata helpers, and `psign-tool code` with Windows AppxSip OS-delegation diagnostics. | Encrypted package crypto/header handling is absent; ZIP-only digest logic is insufficient. | | **AppX extension SIP chain** | Delegates to installed `ExtensionsSip*` providers. | No bundled/provider-specific parity coverage; behavior depends on optional third-party SIP DLLs. | Not implemented. | No extension-provider discovery, DLL contract, or portable provider model. | | **Standalone P7X / PKCX** (`.p7x`) | OS `P7xSip*` can participate when registered; real package signatures are produced as `AppxSignature.p7x` inside signed AppX/MSIX packages. | Direct standalone `.p7x` signing is rejected by current SignTool. | `inspect-pkcs7` accepts raw PKCS#7, bare `SignedData`, and PKCX-wrapped `AppxSignature.p7x`; `extract-pkcx-pkcs7` strips the PKCX wrapper; detached trust remains available through `trust-verify-detached` when caller supplies the detached content. | No standalone `.p7x` signing/export flow mapped to native `/p7*` switches. | @@ -84,8 +84,8 @@ The committed corpus already includes generated unsigned and signed vectors for | Goal | Today | Gap | |------|--------|-----| | **Drop-in Linux replacement for `signtool.exe` sign/verify** | Not supported | Signing and WinTrust-backed verify require Windows CryptAPI/SIP (`SignerSignEx3`, `WinVerifyTrust`). | -| **Drop-in Linux replacement for AzureSignTool** | Partial | **`psign-tool portable sign-pe --azure-key-vault-* --timestamp-url ...`** and **`psign-tool --mode portable sign --azure-key-vault-* --timestamp-url ...`** can build timestamped portable signatures with Key Vault RSA signing. Native-shaped signing supports PE/WinMD, CAB, MSI/MSP, flat MSIX/AppX, NuGet/SNuGet, VSIX, ClickOnce manifests, App Installer descriptors, ZIP, and PowerShell Authenticode scripts; it also supports input lists, continuation, parallelism, and Azure-style batch exits. **`azure-key-vault-sign-digest`** remains available for lower-level **`keys/sign`** workflows. Gaps: catalog targets, WSH scripts, and MSIX/AppX bundles require a dedicated or future route. | -| **Drop-in Linux replacement for Artifact Signing (dlib / REST)** | Partial | PE/WinMD and PowerShell Authenticode scripts (`.ps1`, `.psd1`, `.psm1`, `.ps1xml`, `.psc1`, `.cdxml`, `.mof`) are supported through **`psign-tool --mode portable sign --artifact-signing-* --timestamp-url ...`**; PE/WinMD also supports **`psign-tool portable sign-pe --artifact-signing-* --timestamp-url ...`**. CAB and MSI/MSP are supported through scoped portable commands and native-shaped in-place Artifact Signing; generic catalogs are supported through **`portable sign-catalog --artifact-signing-*`**. Native-shaped portable Artifact Signing supports input file lists, skip-signed, continue-on-error, and max parallelism for supported targets. The lower-level **`artifact-signing-submit`** helper remains available for digest → JSON workflows. Gaps: MSIX/AppX, unsupported non-PE SIP timestamp mutation, and other SIP formats still require Windows dlib mode or future portable embedders. | +| **Drop-in Linux replacement for AzureSignTool** | Partial | **`psign-tool portable sign-pe --azure-key-vault-* --timestamp-url ...`** and **`psign-tool --mode portable sign --azure-key-vault-* --timestamp-url ...`** can build timestamped portable signatures with Key Vault RSA signing. Native-shaped signing supports PE/WinMD, CAB, MSI/MSP, flat MSIX/AppX **and `.msixbundle`/`.appxbundle` bundles**, NuGet/SNuGet, VSIX, ClickOnce manifests, App Installer descriptors, ZIP, and PowerShell Authenticode scripts; it also supports input lists, continuation, parallelism, and Azure-style batch exits. **`azure-key-vault-sign-digest`** remains available for lower-level **`keys/sign`** workflows. Gaps: catalog targets, WSH scripts, MSIX/AppX upload containers, and encrypted packages require a dedicated or future route. | +| **Drop-in Linux replacement for Artifact Signing (dlib / REST)** | Partial | PE/WinMD and PowerShell Authenticode scripts (`.ps1`, `.psd1`, `.psm1`, `.ps1xml`, `.psc1`, `.cdxml`, `.mof`) are supported through **`psign-tool --mode portable sign --artifact-signing-* --timestamp-url ...`**; PE/WinMD also supports **`psign-tool portable sign-pe --artifact-signing-* --timestamp-url ...`**. CAB and MSI/MSP are supported through scoped portable commands and native-shaped in-place Artifact Signing; generic catalogs are supported through **`portable sign-catalog --artifact-signing-*`**. Native-shaped portable Artifact Signing supports input file lists, skip-signed, continue-on-error, and max parallelism for supported targets, including flat `.appx`/`.msix` packages and `.msixbundle`/`.appxbundle` bundles (children must be signed before the bundle, matching native AppxBundleSip). The lower-level **`artifact-signing-submit`** helper remains available for digest → JSON workflows. Gaps: MSIX/AppX upload containers, encrypted packages, unsupported non-PE SIP timestamp mutation, and other SIP formats still require Windows dlib mode or future portable embedders. | | **Linux verify + digest parity for many Authenticode formats** | Supported | **`psign-tool portable`** covers PE, CAB, MSI, ESD/WIM, cleartext MSIX, catalog, scripts; **`trust-verify-*`** adds anchor-based CMS trust (see [`authenticode-trust-stack.md`](authenticode-trust-stack.md)). | | **Maximum Windows-mode Authenticode subject formats** | Windows mode delegates most SIP-registered subjects to OS providers | Remaining gaps are first-class CLI affordances, parity fixtures, generic SIP remove, catalog authoring/member policy, Office/VBA ergonomics, extension SIP coverage, and standalone `.p7x` handling. | | **Maximum portable-mode Authenticode subject formats** | Portable mode covers digest/trust for PE, CAB, MSI, ESD/WIM, cleartext MSIX, catalogs, scripts, and detached PKCS#7; local signing for PE/CAB/MSI/generic catalogs is explicitly scoped; Artifact Signing REST can sign PE/WinMD, PowerShell Authenticode scripts, CAB, MSI/MSP, and generic catalogs | Portable gaps include MSIX signing/embed, unsupported non-PE SIP timestamp mutation, WinTrust/CryptoAPI policy, encrypted MSIX, extension SIPs, Office/VBA, standalone `.p7x`, and package-specific ecosystems. | @@ -102,7 +102,7 @@ These are the highest-leverage gaps after comparing the native switch matrix, po | Priority | Gap id | Current state | Fill plan | |----------|--------|---------------|-----------| -| 1 | `portable-msix-bundle-upload-final-signing` | Flat cleartext `.appx` / `.msix` portable Artifact Signing exists; `psign-tool code` can prepare nested bundle/upload contents and regenerate manifests/block maps, but final bundle/upload signing and encrypted packages remain outside the portable embedder. | Reuse the flat MSIX signer and publisher/block-map preparation, add bundle/upload traversal that signs nested packages before the outer container, define explicit rejection for encrypted packages, and add fixtures for unsigned bundle/upload -> signed verify/tamper cases. | +| 1 | `portable-msix-upload-final-signing` | Flat cleartext `.appx` / `.msix` **and bundle `.msixbundle` / `.appxbundle`** portable Artifact Signing / Key Vault final signing now exist (native `AppxBundleSip` semantics with manifest-only block maps, recursive child validation, and `--skip-signed` coverage); `psign-tool code` prepares nested bundle/upload contents and regenerates manifests/block maps. Upload containers (`.appxupload` / `.msixupload`) and encrypted packages remain outside the portable embedder. | Define upload-container traversal that signs nested flat packages in place, define explicit rejection for encrypted packages, and add fixtures for unsigned upload -> signed verify/tamper cases. | | 2 | `catalog-driver-package-authoring` | Portable `sign-catalog` can author generic CTL catalogs and `verify-catalog-member` can check explicit file membership, while Windows mode can sign/verify existing catalogs and mutate catalog databases. | Extend catalog authoring toward MakeCat/Inf2Cat/New-FileCatalog-compatible member metadata, add driver/INF policy diagnostics separately from generic catalogs, and grow the corpus with psign-authored plus native-authored driver-package catalogs. | | 3 | `wdac-ci-policy-signing` | Detached PKCS#7 and catalog primitives exist, but WDAC / Code Integrity policy signing is documented only as adjacent backlog. | Define policy-file detection and expected signature container shape, route signing through existing detached PKCS#7/catalog CMS helpers, then add verification diagnostics that distinguish CMS validity from Windows deployment/CI policy acceptance. | diff --git a/docs/linux-signing-pipelines.md b/docs/linux-signing-pipelines.md index 2315420..96f43da 100644 --- a/docs/linux-signing-pipelines.md +++ b/docs/linux-signing-pipelines.md @@ -1,6 +1,6 @@ # Linux signing pipelines (what works today) -**`psign-tool portable`** on Linux/macOS can sign PE, CAB, MSI/MSP, flat MSIX/AppX, NuGet/SNuGet, VSIX, ClickOnce manifests, App Installer descriptors, ZIP, and PowerShell scripts through native-shaped portable local PFX/certificate-store or Azure Key Vault routes. It can also sign unsigned single-volume CAB, MSI/MSP, generic catalogs, and RDP files with scoped local RSA/SHA-2 commands; Azure Artifact Signing REST supports its documented native-shaped subset. It still does not provide MSIX/AppX bundle, upload, or encrypted package final signing, OS catalog database policy, or WinTrust policy emulation (see [`rust-sip-gaps.md`](rust-sip-gaps.md)). This page describes **practical portable**, **hybrid**, and **verify-only** flows. +**`psign-tool portable`** on Linux/macOS can sign PE, CAB, MSI/MSP, flat MSIX/AppX, MSIX/AppX bundles (`.msixbundle`/`.appxbundle` — children must be signed first, matching native `AppxBundleSip`), NuGet/SNuGet, VSIX, ClickOnce manifests, App Installer descriptors, ZIP, and PowerShell scripts through native-shaped portable local PFX/certificate-store or Azure Key Vault routes. It can also sign unsigned single-volume CAB, MSI/MSP, generic catalogs, and RDP files with scoped local RSA/SHA-2 commands; Azure Artifact Signing REST supports its documented native-shaped subset. It still does not provide MSIX/AppX upload-container or encrypted package final signing, OS catalog database policy, or WinTrust policy emulation (see [`rust-sip-gaps.md`](rust-sip-gaps.md)). This page describes **practical portable**, **hybrid**, and **verify-only** flows. For tool-by-tool gaps vs **`signtool.exe`**, AzureSignTool, and Artifact Signing, see [`gap-analysis-signing-platforms.md`](gap-analysis-signing-platforms.md). On Windows, for writable copies of native signing binaries outside protected install paths, see [`writable-signing-binaries.md`](writable-signing-binaries.md). @@ -29,7 +29,7 @@ psign-tool portable sign-catalog --cert cert.der --key key.pk8 --output files.ca `sign-catalog` authors generic CTL member entries and signs the catalog PKCS#7. Pair it with `verify-catalog` and `verify-catalog-member --catalog files.cat file1.exe`; driver/INF policy and OS catalog database lookup remain Windows-only. -For native-shaped in-place local signing, use either `--pfx`/`--password` or portable certificate-store material selected by `--sha1`. The route supports PE/WinMD, CAB, MSI/MSP, flat MSIX/AppX, NuGet/SNuGet, VSIX, ClickOnce manifests, App Installer descriptors, ZIP, and PowerShell scripts; it also accepts input file lists and batch controls. Catalog targets, WSH scripts, and MSIX/AppX bundles remain explicit unsupported cases. +For native-shaped in-place local signing, use either `--pfx`/`--password` or portable certificate-store material selected by `--sha1`. The route supports PE/WinMD, CAB, MSI/MSP, flat MSIX/AppX, MSIX/AppX bundles (children must be signed before the bundle), NuGet/SNuGet, VSIX, ClickOnce manifests, App Installer descriptors, ZIP, and PowerShell scripts; it also accepts input file lists and batch controls. Catalog targets, WSH scripts, MSIX/AppX upload containers, and encrypted packages remain explicit unsupported cases. ## 1.2 Portable signing with Azure Key Vault @@ -59,11 +59,11 @@ psign-tool --mode portable sign \ ./MyApp.exe ``` -Portable Key Vault signing supports SHA-256/SHA-384/SHA-512, optional chain certificates (`--chain-cert` on `portable sign-pe`, `--ac` on `--mode portable sign`), and RFC3161 sign-time timestamping through `--timestamp-url` plus `--timestamp-digest`. MSIX/AppX bundles, catalog targets, and WSH scripts remain unsupported by this native-shaped route. `timestamp-pe-rfc3161` remains available as a separate mutation step when you already have a timestamp token or granted response. +Portable Key Vault signing supports SHA-256/SHA-384/SHA-512, optional chain certificates (`--chain-cert` on `portable sign-pe`, `--ac` on `--mode portable sign`), and RFC3161 sign-time timestamping through `--timestamp-url` plus `--timestamp-digest`. Catalog targets, WSH scripts, MSIX/AppX upload containers, and encrypted packages remain unsupported by this native-shaped route. `timestamp-pe-rfc3161` remains available as a separate mutation step when you already have a timestamp token or granted response. ## 1.3 Portable signing with Azure Artifact Signing REST -With **`--features artifact-signing-rest`**, PE/WinMD, CAB, MSI/MSP, flat MSIX/AppX, and generic catalog signing can use Azure Artifact Signing as a REST remote signer without Microsoft client DLLs or SignTool: +With **`--features artifact-signing-rest`**, PE/WinMD, CAB, MSI/MSP, flat MSIX/AppX, MSIX/AppX bundles, and generic catalog signing can use Azure Artifact Signing as a REST remote signer without Microsoft client DLLs or SignTool: ```bash psign-tool portable sign-pe ./MyApp.exe \ @@ -89,7 +89,7 @@ psign-tool --mode portable sign \ This path builds Authenticode CMS locally, sends the CMS authenticated-attributes digest to Artifact Signing `:sign`, embeds the returned RSA signature and signing certificate, then attaches the RFC3161 timestamp before embedding when `timestamp-http` is enabled. For production signatures, keep timestamping enabled because Artifact Signing profile certificates are short-lived. -CAB, MSI/MSP, and flat MSIX/AppX can also use the native-shaped in-place form: +CAB, MSI/MSP, and flat MSIX/AppX plus `.msixbundle`/`.appxbundle` bundles can also use the native-shaped in-place form (children must be signed before the bundle, matching native `AppxBundleSip`): ```bash psign-tool --mode portable sign \ @@ -122,7 +122,7 @@ psign-tool portable sign-catalog \ ./file1.exe ./file2.txt ``` -Non-PE sign-time timestamp mutation is still not a general `SignerTimeStampEx3` replacement for every SIP target, but CAB/MSI/catalog and flat MSIX/AppX Artifact Signing persist RFC3161 tokens in their generated PKCS#7 when built with `timestamp-http`. +Non-PE sign-time timestamp mutation is still not a general `SignerTimeStampEx3` replacement for every SIP target, but CAB/MSI/catalog and flat MSIX/AppX + bundle Artifact Signing persist RFC3161 tokens in their generated PKCS#7 when built with `timestamp-http`. Native-shaped portable Artifact Signing batches can use `--input-file-list`, `--skip-signed`, `--continue-on-error`, and `--max-degree-of-parallelism`: @@ -137,7 +137,7 @@ psign-tool --mode portable sign \ --max-degree-of-parallelism 4 ``` -The file list accepts one path or glob per line; blank lines and `#` comments are ignored. Skip detection verifies PE/WinMD Authenticode digests before skipping, and also covers CAB signatures, MSI/MSP `DigitalSignature` streams, and flat MSIX/AppX `AppxSignature.p7x` packages. +The file list accepts one path or glob per line; blank lines and `#` comments are ignored. Skip detection verifies PE/WinMD Authenticode digests before skipping, and also covers CAB signatures, MSI/MSP `DigitalSignature` streams, and flat or bundle MSIX/AppX `AppxSignature.p7x` packages. ## 1.4 Package-native helper workflows diff --git a/docs/migration-artifact-signing.md b/docs/migration-artifact-signing.md index cf4da9c..241d30a 100644 --- a/docs/migration-artifact-signing.md +++ b/docs/migration-artifact-signing.md @@ -4,7 +4,7 @@ Microsoft **Artifact Signing** (often called **Trusted Signing**) integrates wit **psign-tool** uses the same Win32 bridge as SignTool: **`SignerSignEx3`** with **`SIGNER_DIGEST_SIGN_INFO`** pointing at the DLL exports (this repo prefers **`AuthenticodeDigestSignExWithFileHandle`** when present, matching Microsoft’s Azure dlib). -**psign-tool portable** cannot load the mixed-mode/.NET dlib or call **`SignerSignEx3`**. For PE/WinMD, CAB, MSI/MSP, flat MSIX/AppX packages, and generic catalogs, it can now avoid Microsoft client-side signing tools entirely by building CMS locally, asking Artifact Signing REST to sign the CMS authenticated-attributes digest, and embedding the returned PKCS#7. Other SIP formats, MSIX/AppX bundles/uploads, and encrypted packages still use Windows mode or the dlib bridge until their portable embedders are implemented. +**psign-tool portable** cannot load the mixed-mode/.NET dlib or call **`SignerSignEx3`**. For PE/WinMD, CAB, MSI/MSP, flat MSIX/AppX packages, MSIX/AppX bundles, and generic catalogs, it can now avoid Microsoft client-side signing tools entirely by building CMS locally, asking Artifact Signing REST to sign the CMS authenticated-attributes digest, and embedding the returned PKCS#7. Other SIP formats, MSIX/AppX upload containers, and encrypted packages still use Windows mode or the dlib bridge until their portable embedders are implemented. ### Azure Code Signing **REST** hash signing @@ -68,7 +68,7 @@ psign-tool --mode portable sign \ Authentication choices are mutually exclusive when explicit: use **`--artifact-signing-access-token`**, **`--artifact-signing-managed-identity`** (optionally with **`--artifact-signing-client-id`** or **`--artifact-signing-managed-identity-resource-id`** for user-assigned identities), the service-principal trio **`--artifact-signing-tenant-id`**, **`--artifact-signing-client-id`**, and **`--artifact-signing-client-secret`**, or workload identity with **`--artifact-signing-credential-type workload-identity`** plus tenant/client/token-file inputs or the standard **`AZURE_TENANT_ID`**, **`AZURE_CLIENT_ID`**, and **`AZURE_FEDERATED_TOKEN_FILE`** environment variables. If no explicit credential is supplied, the in-tree Rust default chain tries environment client-secret credentials, workload identity, then managed identity while honoring metadata **`ExcludeCredentials`**. Without metadata, pass **`--artifact-signing-endpoint`** or **`--artifact-signing-region`** plus **`--artifact-signing-account-name`** and **`--artifact-signing-profile-name`**. -Artifact Signing certificates are short-lived; include **`--timestamp-url http://timestamp.acs.microsoft.com/ --timestamp-digest sha256`** for production signatures. Portable PE/WinMD, PowerShell Authenticode scripts (`.ps1`, `.psd1`, `.psm1`, `.ps1xml`, `.psc1`, `.cdxml`, `.mof`), CAB, MSI/MSP, generic catalog, and flat MSIX/AppX Artifact Signing paths attach RFC3161 tokens to the generated Authenticode PKCS#7 when the `timestamp-http` feature is enabled. +Artifact Signing certificates are short-lived; include **`--timestamp-url http://timestamp.acs.microsoft.com/ --timestamp-digest sha256`** for production signatures. Portable PE/WinMD, PowerShell Authenticode scripts (`.ps1`, `.psd1`, `.psm1`, `.ps1xml`, `.psc1`, `.cdxml`, `.mof`), CAB, MSI/MSP, generic catalog, and flat MSIX/AppX + bundle (`.msixbundle`/`.appxbundle`) Artifact Signing paths attach RFC3161 tokens to the generated Authenticode PKCS#7 when the `timestamp-http` feature is enabled. CAB, MSI/MSP, and generic catalogs can use the same Artifact Signing profile through scoped portable commands: @@ -95,7 +95,7 @@ psign-tool portable sign-catalog \ ./file1.exe ./file2.txt ``` -The native-shaped in-place portable `sign` route also supports CAB, MSI/MSP, and flat `.msix` / `.appx` packages with Artifact Signing options. Catalog authoring still uses `portable sign-catalog` because a `.cat` target alone does not describe the member list to author. MSIX/AppX bundle, upload, and encrypted containers remain explicitly unsupported in portable final signing. +The native-shaped in-place portable `sign` route also supports CAB, MSI/MSP, and flat `.msix` / `.appx` packages plus `.msixbundle` / `.appxbundle` bundles with Artifact Signing options (children must be signed before the bundle, matching native `AppxBundleSip`). Catalog authoring still uses `portable sign-catalog` because a `.cat` target alone does not describe the member list to author. MSIX/AppX upload and encrypted containers remain explicitly unsupported in portable final signing. For native-shaped batches, the portable Artifact Signing route accepts the AzureSignTool-style convenience flags: @@ -110,7 +110,7 @@ psign-tool --mode portable sign \ --max-degree-of-parallelism 4 ``` -`--input-file-list` accepts one path or glob per line; blank lines and `#` comments are ignored. `--skip-signed` skips PE/WinMD files only when existing Authenticode digest verification succeeds, and also skips CAB, MSI/MSP, and flat MSIX/AppX files that already contain embedded signature material. `--continue-on-error` preserves per-file failure diagnostics and returns a non-zero batch exit code when any target fails. +`--input-file-list` accepts one path or glob per line; blank lines and `#` comments are ignored. `--skip-signed` skips PE/WinMD files only when existing Authenticode digest verification succeeds, and also skips CAB, MSI/MSP, and flat or bundle MSIX/AppX files that already contain embedded signature material. `--continue-on-error` preserves per-file failure diagnostics and returns a non-zero batch exit code when any target fails. ## Flag mapping (Microsoft sample → psign-tool) diff --git a/docs/migration-azuresigntool.md b/docs/migration-azuresigntool.md index e7e3e84..f9001d1 100644 --- a/docs/migration-azuresigntool.md +++ b/docs/migration-azuresigntool.md @@ -1,6 +1,6 @@ # Migrating from AzureSignTool -This project can replace **AzureSignTool** for Windows signing when built with **`--features azure-kv-sign`**. **`psign-tool portable`** covers digest checks, verification, and (with **`--features azure-kv-sign-portable`**) Key Vault **`keys/sign`** on digest files plus portable PE Authenticode signing through **`portable sign-pe`**. Native-shaped **`--mode portable sign --azure-key-vault-*`** supports PE/WinMD, CAB, MSI/MSP, flat MSIX/AppX, NuGet/SNuGet, VSIX, ClickOnce manifests, App Installer descriptors, ZIP, and PowerShell Authenticode script formats (`.ps1`, `.psd1`, `.psm1`, `.ps1xml`, `.psc1`, `.cdxml`, `.mof`). Windows mode remains the broader native-shaped signing path. +This project can replace **AzureSignTool** for Windows signing when built with **`--features azure-kv-sign`**. **`psign-tool portable`** covers digest checks, verification, and (with **`--features azure-kv-sign-portable`**) Key Vault **`keys/sign`** on digest files plus portable PE Authenticode signing through **`portable sign-pe`**. Native-shaped **`--mode portable sign --azure-key-vault-*`** supports PE/WinMD, CAB, MSI/MSP, flat MSIX/AppX, MSIX/AppX bundles (`.msixbundle`/`.appxbundle` — children must be signed before the bundle), NuGet/SNuGet, VSIX, ClickOnce manifests, App Installer descriptors, ZIP, and PowerShell Authenticode script formats (`.ps1`, `.psd1`, `.psm1`, `.ps1xml`, `.psc1`, `.cdxml`, `.mof`). Windows mode remains the broader native-shaped signing path. **Azure Artifact Signing (Trusted Signing)** via Microsoft’s decoupled **`Azure.CodeSigning.Dlib.dll`** is **not** the Key Vault path: use **`--dlib`** / **`--trusted-signing-dlib-root`** with **`--dmdf`** only (never mixed with **`--azure-key-vault-url`**). See [`migration-artifact-signing.md`](migration-artifact-signing.md). PowerShell OpenAuthenticode overlap (inspect JSON, REST submit, EKU prefix selection) is summarized in [`psa-interoperability.md`](psa-interoperability.md). @@ -101,7 +101,7 @@ psign-tool --mode portable sign \ ./MyApp.exe ``` -The portable Key Vault path supports SHA-2 digests, Key Vault signer certificates, optional **`--ac` / `--chain-cert`** certificates, and RFC3161 sign-time timestamping. MSIX/AppX bundles, catalog targets, and WSH scripts remain unsupported by this native-shaped route; use the dedicated catalog command where applicable. Use **`psign-tool portable timestamp-pe-rfc3161`** as a second portable step only when you already have a timestamp token/response. +The portable Key Vault path supports SHA-2 digests, Key Vault signer certificates, optional **`--ac` / `--chain-cert`** certificates, and RFC3161 sign-time timestamping. MSIX/AppX upload containers and encrypted packages, catalog targets, and WSH scripts remain unsupported by this native-shaped route; use the dedicated catalog command where applicable. Use **`psign-tool portable timestamp-pe-rfc3161`** as a second portable step only when you already have a timestamp token/response. ### Linux / CI: Key Vault **`keys/sign`** on a raw digest diff --git a/docs/psign-cli-matrix.json b/docs/psign-cli-matrix.json index 1ff4916..4b9ef9d 100644 --- a/docs/psign-cli-matrix.json +++ b/docs/psign-cli-matrix.json @@ -24,7 +24,7 @@ {"id": "orchestrate", "meaning": "Plan and execute nested inside-out signing flows across files, manifests, and package containers."} ], "top_gap_ids": [ - "portable-msix-bundle-upload-final-signing", + "portable-msix-upload-final-signing", "catalog-driver-package-authoring", "wdac-ci-policy-signing" ], @@ -177,7 +177,7 @@ ], "code": [ {"native": "(dotnet/sign-style)", "rust": "code --dry-run --plan-json --base-directory --file-list ", "tier": "P1", "status": "implemented", "notes": "Plans file-list/glob selection plus nested ZIP/OPC inside-out ordering without modifying inputs."}, - {"native": "(dotnet/sign-style)", "rust": "code (--cert --key |--pfx [--password ]|--sha1 [--cert-store-dir ]|--azure-key-vault-url --azure-key-vault-certificate |--artifact-signing-endpoint --artifact-signing-account-name --artifact-signing-profile-name ) --output [--max-concurrency ] [--skip-signed|--overwrite] ", "tier": "P1", "status": "partial", "notes": "Guarded execution for local RSA cert/key, PFX, portable cert-store SHA-1, Azure Key Vault, or Artifact Signing identity over PE/WinMD, package-native NuGet/SNuGet, VSIX, generic ZIP nested package entries, unsigned MSIX/AppX prepare with --publisher-name and AppxBlockMap regeneration, MSIX/AppX upload/bundle nested package prepare, encrypted MSIX/AppX OS-only diagnostics, ClickOnce .manifest/.application/.vsto XMLDSig signing, PE-like ClickOnce .deploy payloads, namespace-aware App Installer publisher updates plus top-level and nested ZIP companion signatures, --continue-on-error, --max-concurrency for independent top-level inputs, --skip-signed, --overwrite, and VSIX/ZIP/MSIX -> NuGet/VSIX -> PE/WinMD/ClickOnce-manifest/App-Installer-companion nested inside-out signing. Unsupported non-PE nested Authenticode payloads fail explicitly unless excluded."} + {"native": "(dotnet/sign-style)", "rust": "code (--cert --key |--pfx [--password ]|--sha1 [--cert-store-dir ]|--azure-key-vault-url --azure-key-vault-certificate |--artifact-signing-endpoint --artifact-signing-account-name --artifact-signing-profile-name ) --output [--max-concurrency ] [--skip-signed|--overwrite] ", "tier": "P1", "status": "partial", "notes": "Guarded execution for local RSA cert/key, PFX, portable cert-store SHA-1, Azure Key Vault, or Artifact Signing identity over PE/WinMD, package-native NuGet/SNuGet, VSIX, generic ZIP nested package entries, unsigned MSIX/AppX prepare with --publisher-name and AppxBlockMap regeneration (bundle layouts regenerate a manifest-only bundle block map and update AppxBundleManifest.xml Identity/Package Publisher), MSIX/AppX upload/bundle nested package prepare, encrypted MSIX/AppX OS-only diagnostics, ClickOnce .manifest/.application/.vsto XMLDSig signing, PE-like ClickOnce .deploy payloads, namespace-aware App Installer publisher updates plus top-level and nested ZIP companion signatures, --continue-on-error, --max-concurrency for independent top-level inputs, --skip-signed, --overwrite, and VSIX/ZIP/MSIX -> NuGet/VSIX -> PE/WinMD/ClickOnce-manifest/App-Installer-companion nested inside-out signing. Unsupported non-PE nested Authenticode payloads fail explicitly unless excluded."} ] }, "tier_summary": { diff --git a/docs/psign-cli-matrix.md b/docs/psign-cli-matrix.md index aa846e6..54c6cb0 100644 --- a/docs/psign-cli-matrix.md +++ b/docs/psign-cli-matrix.md @@ -61,7 +61,7 @@ The roadmap choice is maintained in [`gap-analysis-signing-platforms.md`](gap-an | Gap id | Why it is high value | |--------|----------------------| -| `portable-msix-bundle-upload-final-signing` | Closes the largest remaining Linux/Artifact Signing package gap after flat MSIX/AppX support. | +| `portable-msix-upload-final-signing` | Closes the remaining Linux/Artifact Signing package gap now that flat and bundle MSIX/AppX signing are portable. | | `catalog-driver-package-authoring` | Turns existing catalog signing/member verification into a fuller driver/package catalog workflow. | | `wdac-ci-policy-signing` | Builds on detached PKCS#7/catalog primitives for a security-policy workflow that is adjacent to existing Authenticode users. | diff --git a/docs/rust-sip-gaps.md b/docs/rust-sip-gaps.md index eb0c9b0..d35fa58 100644 --- a/docs/rust-sip-gaps.md +++ b/docs/rust-sip-gaps.md @@ -40,7 +40,7 @@ VSIX and NuGet package signatures should not be modeled as Rust SIP gaps. VSIX u | Item | Status | |------|--------| -| PE/CAB/MSI **PKCS#7 encode** + format embed entirely in Rust | **Implemented for PE RSA/SHA-2:** **`psign-tool portable sign-pe`** computes the PE Authenticode digest, creates Authenticode **`SignedData`** from scratch, wraps it in a **`WIN_CERTIFICATE`**, replaces existing PE signatures by default, supports explicit append mode with **`--append-signature`**, and recomputes **`CheckSum`**. **Implemented for unsigned single-volume CAB RSA/SHA-2:** **`psign-tool portable sign-cab`** inserts the CAB Authenticode reserve header, creates CAB **`SpcIndirectDataContent`**, appends tail PKCS#7, and verifies through **`verify-cab`**. **Implemented for MSI/MSP RSA/SHA-2:** **`psign-tool portable sign-msi`** creates MSI **`SpcSigInfo`** indirect data and writes the root **`\u{5}DigitalSignature`** stream. **`pe_embed`** still exposes **`wrap_pkcs7_der_authenticode_win_certificate`** + **`pe_append_authenticode_pkcs7_certificate`** for lower-level PE flows. **`pkcs7.rs`** now includes PE, CAB, and MSI **`SpcIndirectDataContent`** construction, local RSA/SHA-256/384/512 CMS signing, and remote RSA signature injection helpers in addition to PKCS#9 **`messageDigest`** extract/replace, authenticated-attribute **`SET OF Attribute`** DER, **`signer_info_sha256_digest_over_signed_attrs`**, **`signer_info_clone_with_signed_attrs`** / **`signer_info_clone_with_signature_octets`**, **`signed_data_replace_signer_info_at`**, and **`signed_data_replace_first_signer_info`**. Remaining gaps: ECDSA attribute-sign rules, optional attr tweaks beyond PKCS#9 **`messageDigest`**, broad top-level `sign` routing, CAB replacement/multivolume cases, `MsiDigitalSignatureEx` authoring, MSIX production embedding, and non-PE timestamp mutation. | +| PE/CAB/MSI **PKCS#7 encode** + format embed entirely in Rust | **Implemented for PE RSA/SHA-2:** **`psign-tool portable sign-pe`** computes the PE Authenticode digest, creates Authenticode **`SignedData`** from scratch, wraps it in a **`WIN_CERTIFICATE`**, replaces existing PE signatures by default, supports explicit append mode with **`--append-signature`**, and recomputes **`CheckSum`**. **Implemented for unsigned single-volume CAB RSA/SHA-2:** **`psign-tool portable sign-cab`** inserts the CAB Authenticode reserve header, creates CAB **`SpcIndirectDataContent`**, appends tail PKCS#7, and verifies through **`verify-cab`**. **Implemented for MSI/MSP RSA/SHA-2:** **`psign-tool portable sign-msi`** creates MSI **`SpcSigInfo`** indirect data and writes the root **`\u{5}DigitalSignature`** stream. **Implemented for flat `.msix`/`.appx` and `.msixbundle`/`.appxbundle` RSA/SHA-2:** native-shaped **`--mode portable sign`** (local PFX/certificate-store, Azure Key Vault, or Artifact Signing) stages the package, builds the APPX **`SpcIndirectDataContent`** blob, embeds **`AppxSignature.p7x`** (**`PKCX`**), and validates through **`verify-msix`**; bundle staging follows native **`AppxBundleSip`** semantics (manifest-only block map, byte-identical children, recursive child verification — children must be signed before the bundle). **`pe_embed`** still exposes **`wrap_pkcs7_der_authenticode_win_certificate`** + **`pe_append_authenticode_pkcs7_certificate`** for lower-level PE flows. **`pkcs7.rs`** now includes PE, CAB, and MSI **`SpcIndirectDataContent`** construction, local RSA/SHA-256/384/512 CMS signing, and remote RSA signature injection helpers in addition to PKCS#9 **`messageDigest`** extract/replace, authenticated-attribute **`SET OF Attribute`** DER, **`signer_info_sha256_digest_over_signed_attrs`**, **`signer_info_clone_with_signed_attrs`** / **`signer_info_clone_with_signature_octets`**, **`signed_data_replace_signer_info_at`**, and **`signed_data_replace_first_signer_info`**. Remaining gaps: ECDSA attribute-sign rules, optional attr tweaks beyond PKCS#9 **`messageDigest`**, broad top-level `sign` routing, CAB replacement/multivolume cases, `MsiDigitalSignatureEx` authoring, upload-container (`.appxupload`/`.msixupload`) final signing, and non-PE timestamp mutation. | | **MSIX/Appx `CryptSIPDllCreateIndirectData`** | **`AppxSipCreateIndirectData`** / **`AppxBundleSipCreateIndirectData`** build the **APPX `SpcIndirectData`** blob at sign time; **`msix_digest`** only **verifies** recomputed AX\* vs PKCS#7 — see [`windows-signing-components.md`](windows-signing-components.md) (**AppxSip.dll**) and [`rust-sip-spec-refs.md`](rust-sip-spec-refs.md). | | **RFC3161** timestamp construction in Rust | **Partial:** `crates/psign-sip-digest/src/timestamp.rs` — **`build_timestamp_request_bytes`** encodes **DER** **`TimeStampReq`** (version 1 + **`MessageImprint`** + optional **`nonce`** / **`certReq`**) for SHA-1 / SHA-256 / SHA-384 / SHA-512; **`parse_time_stamp_resp_der`** reads **`PKIStatusInfo.status`**, optional **`statusString`**, optional **`failInfo`**, plus optional raw **`timeStampToken`**; **`parse_time_stamp_token_tst_info`** structurally extracts CMS **`id-ct-TSTInfo`** policy OID, message-imprint digest OID/hash, serial, **`genTime`**, and nonce. **`psign-tool portable`** exposes **`rfc3161-timestamp-req`**, **`rfc3161-timestamp-resp-inspect`**, optional **`rfc3161-timestamp-http-post`** with **`--features timestamp-http`**, **`sign-pe --timestamp-url --timestamp-digest`** for PE sign-time timestamping, and **`timestamp-pe-rfc3161`** to attach a raw token or granted **`TimeStampResp`** token to a PE `SignerInfo` unsigned attribute. Portable trust uses cryptographic RFC3161 validation when both **`--prefer-timestamp-signing-time`** and **`--require-valid-timestamp`** are set: nested token **`MessageImprint`** over primary **`SignerInfo.signature`**, timestamp CMS **`messageDigest`**, RSA/SHA-256 timestamp signature, TSA `timeStamping` EKU, and explicit-anchor TSA chain. Microsoft/ACS tokens with fractional-second **`genTime`** and attribute certificates in the timestamp bag are handled (fractional seconds truncated; non-X.509 **`CertificateChoices`** stripped). PKCS#9 **`signing-time`** still works only for non-required instant selection. Remaining gaps: delegated/non-RSA TSA support, non-PE timestamp mutation, and full Windows **`CryptVerifyTimeStampSignature`** parity. | | **`/ph`** **page hashes** (`SPC_PE_IMAGE_PAGE_HASHES`) | Portable **CMS extract** + **payload peel** + **flat `(offset,digest)*` parse** + **experimental contiguous file-offset verify** (`page_hashes`, CLI **`pe-has-page-hashes`** / **`pe-page-hash-info`** / **`verify-pe-page-hashes`**). Differs from **`WinVerifyTrust`** where checksum / security-directory handling diverges — native **`verify --verify-page-hashes`** remains the strict `/ph` reference. | diff --git a/src/code.rs b/src/code.rs index dfad938..ea946b5 100644 --- a/src/code.rs +++ b/src/code.rs @@ -1208,6 +1208,10 @@ fn prepare_msix_family_bytes( timestamp_digest: Option, ) -> Result> { ensure_unsigned_msix_family(input_bytes, label)?; + // True bundles (.msixbundle/.appxbundle) always carry AppxMetadata/AppxBundleManifest.xml; + // upload containers (.appxupload/.msixupload) are dotnet packaging wrappers whose + // nested flat packages carry their own AppxManifest.xml. + let is_true_bundle = matches!(format, CodeFormat::MsixBundle | CodeFormat::AppxBundle); let mut updated = sign_nested_package_entries( input_bytes, label, @@ -1226,12 +1230,25 @@ fn prepare_msix_family_bytes( if let Some(publisher) = publisher { if zip_contains_entry(&updated, "AppxManifest.xml")? { updated = update_msix_manifest_publisher_bytes(&updated, label, publisher)?; + } else if zip_contains_entry(&updated, "AppxMetadata/AppxBundleManifest.xml")? { + updated = update_msix_bundle_manifest_publisher_bytes(&updated, label, publisher)?; } else if matches!(format, CodeFormat::Msix | CodeFormat::Appx) { return Err(anyhow!("{label} is missing AppxManifest.xml")); + } else if is_true_bundle { + return Err(anyhow!( + "{label} is missing AppxMetadata/AppxBundleManifest.xml" + )); } } + // Bundle block maps cover only the bundle manifest; flat/upload layouts hash all payloads. + let is_bundle_layout = zip_contains_entry(&updated, "AppxMetadata/AppxBundleManifest.xml")?; if zip_contains_entry(&updated, "AppxBlockMap.xml")? { - updated = regenerate_msix_block_map_bytes(&updated, label)?; + let block_map = if is_bundle_layout { + build_msix_bundle_block_map_xml(&updated)? + } else { + build_msix_block_map_xml(&updated)? + }; + updated = repack_with_block_map(&updated, label, block_map)?; } Ok(updated) } @@ -1304,8 +1321,7 @@ fn update_appinstaller_publisher_bytes(bytes: &[u8], publisher: &str) -> Result< Ok(updated.into_bytes()) } -fn regenerate_msix_block_map_bytes(input_bytes: &[u8], label: &str) -> Result> { - let block_map = build_msix_block_map_xml(input_bytes)?; +fn repack_with_block_map(input_bytes: &[u8], label: &str, block_map: Vec) -> Result> { let mut out = Cursor::new(Vec::new()); repack_zip_with_updates( Cursor::new(input_bytes), @@ -1320,6 +1336,76 @@ fn regenerate_msix_block_map_bytes(input_bytes: &[u8], label: &str) -> Result Result> { + let mut archive = + zip::ZipArchive::new(Cursor::new(input_bytes)).context("open MSIX/AppX bundle ZIP")?; + let mut manifest = archive + .by_name("AppxMetadata/AppxBundleManifest.xml") + .context("read AppxMetadata/AppxBundleManifest.xml")?; + let mut bytes = Vec::with_capacity(manifest.size() as usize); + manifest.read_to_end(&mut bytes)?; + drop(manifest); + + let mut xml = String::new(); + xml.push_str(r#""#); + xml.push_str(r#""#); + xml.push_str(&format!( + r#""#, + bytes.len() + )); + for chunk in bytes.chunks(64 * 1024) { + let hash = sha2::Sha256::digest(chunk); + xml.push_str(&format!( + r#""#, + BASE64_STANDARD.encode(hash), + chunk.len() + )); + } + xml.push_str(""); + Ok(xml.into_bytes()) +} + +fn update_msix_bundle_manifest_publisher_bytes( + input_bytes: &[u8], + label: &str, + publisher: &str, +) -> Result> { + if publisher.is_empty() { + return Err(anyhow!("MSIX/AppX publisher cannot be empty")); + } + let escaped = xml_escape_attr(publisher); + let mut archive = zip::ZipArchive::new(Cursor::new(input_bytes)) + .with_context(|| format!("open MSIX/AppX bundle {label}"))?; + let mut manifest = archive + .by_name("AppxMetadata/AppxBundleManifest.xml") + .with_context(|| format!("read AppxMetadata/AppxBundleManifest.xml in {label}"))?; + let mut text = String::new(); + manifest + .read_to_string(&mut text) + .context("read AppxMetadata/AppxBundleManifest.xml as UTF-8")?; + drop(manifest); + + // Identity@Publisher describes the bundle; Package@Publisher mirrors the child packages + // so both stay in sync with the requested signing subject. + let mut updated = update_attr_for_local_tags(&text, "Identity", "Publisher", &escaped)?; + updated = update_attr_for_local_tags(&updated, "Package", "Publisher", &escaped)?; + + let mut out = Cursor::new(Vec::new()); + repack_zip_with_updates( + Cursor::new(input_bytes), + &mut out, + vec![ZipEntryUpdate { + name: "AppxMetadata/AppxBundleManifest.xml".to_owned(), + bytes: updated.into_bytes(), + compression: zip::CompressionMethod::Deflated, + }], + ) + .with_context(|| format!("repack {label} with updated AppxBundleManifest.xml"))?; + Ok(out.into_inner()) +} + fn build_msix_block_map_xml(input_bytes: &[u8]) -> Result> { let mut archive = zip::ZipArchive::new(Cursor::new(input_bytes)).context("open MSIX/AppX ZIP")?; diff --git a/src/lib.rs b/src/lib.rs index 3c9d6f1..c457c48 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -144,6 +144,14 @@ fn portable_command_for_path(path: &std::path::Path) -> anyhow::Result<&'static "msi" | "msp" => Ok("verify-msi"), "wim" | "esd" => Ok("verify-esd"), "msix" | "appx" | "msixbundle" | "appxbundle" => Ok("verify-msix"), + "eappx" | "eappxbundle" | "emsix" | "emsixbundle" => Err(anyhow::anyhow!( + "encrypted MSIX/AppX packages (.{ext}) require Windows AppxSip OS delegation; portable verify cannot rehash encrypted package stores for {}", + path.display() + )), + "appxupload" | "msixupload" => Err(anyhow::anyhow!( + "MSIX/AppX upload bundles (.{ext}) are dotnet/SignTool-style packaging containers, not AppX SIP verify subjects; prepare nested packages with `psign-tool code` instead: {}", + path.display() + )), "zip" => Ok("verify-zip"), "cat" => Ok("verify-catalog"), "ps1" | "psd1" | "psm1" | "ps1xml" | "psc1" | "cdxml" | "mof" | "js" | "vbs" | "wsf" => { diff --git a/src/portable_remove.rs b/src/portable_remove.rs index 579b18a..49112ac 100644 --- a/src/portable_remove.rs +++ b/src/portable_remove.rs @@ -189,12 +189,14 @@ fn decode_utf16(bytes: &[u8], little_endian: bool) -> Result { return Err(anyhow!("UTF-16 script has an odd byte length")); } let words = bytes - .chunks_exact(2) + .as_chunks::<2>() + .0 + .iter() .map(|pair| { if little_endian { - u16::from_le_bytes([pair[0], pair[1]]) + u16::from_le_bytes(*pair) } else { - u16::from_be_bytes([pair[0], pair[1]]) + u16::from_be_bytes(*pair) } }) .collect::>(); diff --git a/src/portable_sign.rs b/src/portable_sign.rs index 661cd2b..6dda031 100644 --- a/src/portable_sign.rs +++ b/src/portable_sign.rs @@ -363,7 +363,7 @@ fn target_should_skip_signed(target: &Path) -> Result { std::fs::read(target).with_context(|| format!("read '{}'", target.display()))?; Ok(psign_sip_digest::msi_digest::msi_digital_signature_pkcs7_der(&bytes).is_ok()) } - "appx" | "msix" => { + "msix" | "appx" | "msixbundle" | "appxbundle" => { Ok(psign_sip_digest::msix_digest::verify_msix_digest_consistency(target).is_ok()) } _ => Ok(false), @@ -639,9 +639,15 @@ fn validate_portable_core_target( append_signature: bool, ) -> Result { let ext = target_extension_lower(target); - if matches!(ext.as_str(), "msixbundle" | "appxbundle") { + if psign_sip_digest::msix_digest::is_encrypted_msix_extension(&ext) { return Err(anyhow!( - "portable signing supports flat MSIX/AppX packages but not MSIX/AppX bundles: {}", + "portable signing does not support encrypted MSIX/AppX packages (.{ext}); encrypted packages require Windows AppxSip OS delegation: {}", + target.display() + )); + } + if matches!(ext.as_str(), "appxupload" | "msixupload") { + return Err(anyhow!( + "portable signing does not support MSIX/AppX upload bundles (.{ext}); upload containers wrap flat packages produced by `dotnet/SignTool`-style tooling and are not AppX SIP verify subjects: {}", target.display() )); } @@ -660,7 +666,7 @@ fn validate_portable_core_target( } psign_portable_core::PortableFileFormat::Unknown => { return Err(anyhow!( - "portable native-shaped signing supports PE/WinMD, CAB, MSI/MSP, flat MSIX/AppX, NuGet, VSIX, ClickOnce manifests, App Installer, ZIP, and PowerShell scripts; got {}", + "portable native-shaped signing supports PE/WinMD, CAB, MSI/MSP, MSIX/AppX packages and bundles, NuGet, VSIX, ClickOnce manifests, App Installer, ZIP, and PowerShell scripts; got {}", target.display() )); } @@ -738,12 +744,14 @@ fn sign_one_target_artifact_signing(target: &Path, args: &SignArgs) -> Result<() )), "cab" => run_portable_sign_cab_artifact_signing(target, &tmp, args), "msi" | "msp" => run_portable_sign_msi_artifact_signing(target, &tmp, args), - "appx" | "msix" => run_portable_sign_msix_artifact_signing(target, &tmp, args), + "appx" | "msix" | "msixbundle" | "appxbundle" => { + run_portable_sign_msix_artifact_signing(target, &tmp, args) + } "cat" => Err(anyhow!( "portable Artifact Signing for catalog targets is available through `psign-tool portable sign-catalog ... --artifact-signing-*`; native-shaped in-place .cat signing needs a catalog-authenticode replacement path and is not implemented yet" )), _ => Err(anyhow!( - "portable Artifact Signing is currently implemented for PE/WinMD, PowerShell Authenticode scripts (.ps1, .psd1, .psm1, .ps1xml, .psc1, .cdxml, .mof), CAB, MSI/MSP, and flat MSIX/AppX targets; got {}", + "portable Artifact Signing is currently implemented for PE/WinMD, PowerShell Authenticode scripts (.ps1, .psd1, .psm1, .ps1xml, .psc1, .cdxml, .mof), CAB, MSI/MSP, and MSIX/AppX package or bundle targets; got {}", target.display() )), } @@ -1448,7 +1456,7 @@ fn batch_exit_code(exit_style: SignExitCodes, successes: usize, failures: usize) fn reject_option(name: &str, present: bool) -> Result<()> { if present { return Err(anyhow!( - "portable sign does not support {name}; local PFX/certificate-store and Azure Key Vault signing support PE/WinMD, CAB, MSI/MSP, flat MSIX/AppX, NuGet, VSIX, ClickOnce manifests, App Installer, ZIP, and PowerShell scripts, while Azure Artifact Signing supports its documented native-shaped subset" + "portable sign does not support {name}; local PFX/certificate-store and Azure Key Vault signing support PE/WinMD, CAB, MSI/MSP, MSIX/AppX packages and bundles, NuGet, VSIX, ClickOnce manifests, App Installer, ZIP, and PowerShell scripts, while Azure Artifact Signing supports its documented native-shaped subset" )); } Ok(()) diff --git a/src/response_argv.rs b/src/response_argv.rs index 19e372b..5e23c0d 100644 --- a/src/response_argv.rs +++ b/src/response_argv.rs @@ -39,11 +39,11 @@ fn utf16_bytes_to_string(bytes: &[u8], big_endian: bool) -> Result { )); } let mut units = Vec::with_capacity(bytes.len() / 2); - for pair in bytes.chunks_exact(2) { + for pair in bytes.as_chunks::<2>().0 { let u = if big_endian { - u16::from_be_bytes([pair[0], pair[1]]) + u16::from_be_bytes(*pair) } else { - u16::from_le_bytes([pair[0], pair[1]]) + u16::from_le_bytes(*pair) }; units.push(u); } diff --git a/src/win/code_sign_format.rs b/src/win/code_sign_format.rs index 097a192..eaa27e5 100644 --- a/src/win/code_sign_format.rs +++ b/src/win/code_sign_format.rs @@ -61,7 +61,7 @@ pub fn detect(path: &Path) -> CodeSignFormat { } "winmd" => CodeSignFormat::WindowsMetadata, "appx" | "appxbundle" | "msix" | "msixbundle" | "eappx" | "eappxbundle" | "emsix" - | "emsixbundle" => CodeSignFormat::MsixFamily, + | "emsixbundle" | "appxupload" | "msixupload" => CodeSignFormat::MsixFamily, "msi" | "msp" | "mst" => CodeSignFormat::WindowsInstaller, "wim" | "esd" => CodeSignFormat::WimImage, "cat" => CodeSignFormat::Catalog, @@ -172,6 +172,14 @@ mod tests { CodeSignFormat::MsixFamily ); assert_eq!(detect(Path::new("pkg.eappx")), CodeSignFormat::MsixFamily); + assert_eq!( + detect(Path::new("pkg.msixupload")), + CodeSignFormat::MsixFamily + ); + assert_eq!( + detect(Path::new("pkg.appxupload")), + CodeSignFormat::MsixFamily + ); assert_eq!( detect(Path::new(r"C:\scripts\run.JS")), CodeSignFormat::WindowsScriptHost diff --git a/src/win/rdp.rs b/src/win/rdp.rs index 3649be7..6366c5f 100644 --- a/src/win/rdp.rs +++ b/src/win/rdp.rs @@ -388,8 +388,10 @@ fn read_rdp_hash_algorithm_oid() -> Result> { return Err(anyhow!("read RDP HashAlgorithm registry value: {status:?}")); } let words: Vec = buf - .chunks_exact(2) - .map(|c| u16::from_le_bytes([c[0], c[1]])) + .as_chunks::<2>() + .0 + .iter() + .map(|c| u16::from_le_bytes(*c)) .take_while(|&w| w != 0) .collect(); Ok(Some(String::from_utf16_lossy(&words))) diff --git a/src/win/sealing.rs b/src/win/sealing.rs index 41f0e6c..47d64fd 100644 --- a/src/win/sealing.rs +++ b/src/win/sealing.rs @@ -19,6 +19,8 @@ pub fn is_appx_family(path: &std::path::Path) -> bool { | "eappxbundle" | "emsix" | "emsixbundle" + | "appxupload" + | "msixupload" ) } diff --git a/src/win/sign_core.rs b/src/win/sign_core.rs index 86d39c7..e01a1c9 100644 --- a/src/win/sign_core.rs +++ b/src/win/sign_core.rs @@ -566,9 +566,11 @@ pub(crate) fn infer_digest_for_cert(cert: *const CERT_CONTEXT) -> Result = buf - .chunks_exact(2) - .map(|c| u16::from_le_bytes([c[0], c[1]])) - .take_while(|&x| x != 0) + .as_chunks::<2>() + .0 + .iter() + .map(|c| u16::from_le_bytes(*c)) + .take_while(|x| *x != 0) .collect(); let s = String::from_utf16_lossy(&wide).to_ascii_uppercase(); if s.contains("SHA512") { diff --git a/tests/cli_pe_digest.rs b/tests/cli_pe_digest.rs index c2de711..8f23280 100644 --- a/tests/cli_pe_digest.rs +++ b/tests/cli_pe_digest.rs @@ -1423,6 +1423,67 @@ fn msix_manifest_info_and_set_publisher_update_identity() { )); } +#[test] +fn msix_manifest_info_and_set_publisher_support_bundles() { + let input = repo_root().join("tests/fixtures/generated-unsigned/msix/sample.msixbundle"); + + let mut info = portable_cmd(); + info.arg("msix-manifest-info").arg(&input); + info.assert() + .success() + .stdout(predicate::str::contains("package_name=Psign.ParityMinimal")) + .stdout(predicate::str::contains( + "publisher=CN=Test Code Signing Certificate", + )) + .stdout(predicate::str::contains("version=2026.514.1330.0")); + + let dir = tempfile::tempdir().unwrap(); + let output = dir.path().join("updated.msixbundle"); + let publisher = "CN=Updated Bundle Publisher"; + let mut update = portable_cmd(); + update + .arg("msix-set-publisher") + .arg(&input) + .arg("--publisher") + .arg(publisher) + .arg("--output") + .arg(&output); + update.assert().success().stdout(predicate::str::contains( + "publisher=CN=Updated Bundle Publisher", + )); + + // Both Identity@Publisher and Package@Publisher mirrors are updated, and the + // bundle manifest stays readable by the same helper afterwards. + let mut updated_info = portable_cmd(); + updated_info.arg("msix-manifest-info").arg(&output); + updated_info + .assert() + .success() + .stdout(predicate::str::contains( + "publisher=CN=Updated Bundle Publisher", + )) + .stdout(predicate::str::contains( + "package_publisher=CN=Updated Bundle Publisher", + )); + + let mut manifest = String::new(); + { + let mut archive = zip::ZipArchive::new(std::fs::File::open(&output).unwrap()).unwrap(); + archive + .by_name("AppxMetadata/AppxBundleManifest.xml") + .unwrap() + .read_to_string(&mut manifest) + .unwrap(); + } + assert_eq!( + manifest + .matches(r#"Publisher="CN=Updated Bundle Publisher""#) + .count(), + 2, + "Identity@Publisher and Package@Publisher must both be updated:\n{manifest}" + ); +} + #[test] fn clickonce_deploy_info_and_copy_payload_use_content_name() { let dir = tempfile::tempdir().unwrap(); @@ -6061,6 +6122,185 @@ fn mode_portable_artifact_signing_signs_flat_msix() { verify.assert().success(); } +#[cfg(all(feature = "timestamp-server", feature = "artifact-signing-rest"))] +#[test] +fn mode_portable_artifact_signing_signs_msixbundle() { + let dir = tempfile::tempdir().unwrap(); + for name in ["sample.msixbundle", "sample.appxbundle"] { + // Native AppxBundleSip requires child packages to be signed before the bundle: + // sign the flat child first, then repack an unsigned bundle around it. + let child_ext = if name.ends_with("msixbundle") { + "sample.msix" + } else { + "sample.appx" + }; + let child_path = dir.path().join(child_ext); + std::fs::copy( + repo_root().join(format!( + "tests/fixtures/generated-unsigned/msix/{child_ext}" + )), + &child_path, + ) + .expect("copy unsigned child package fixture"); + let (mut child_guard, child_endpoint) = spawn_psign_artifact_signing_server(2); + let mut child_cmd = Command::cargo_bin("psign-tool").unwrap(); + child_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("--artifact-signing-endpoint-base-url") + .arg(&child_endpoint) + .arg(&child_path); + child_cmd + .assert() + .success() + .stdout(predicate::str::contains("Signed:")); + let status = child_guard.0.wait().expect("child server exit"); + assert!(status.success(), "child server failed with {status}"); + + let bundle_path = repack_unsigned_bundle(&dir, &child_path, name); + let (mut guard, endpoint) = spawn_psign_artifact_signing_server(2); + 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("--artifact-signing-endpoint-base-url") + .arg(&endpoint) + .arg(&bundle_path); + cmd.assert() + .success() + .stdout(predicate::str::contains("Signed:")); + let status = guard.0.wait().expect("server exit"); + assert!(status.success(), "server failed with {status}"); + + let mut verify = portable_cmd(); + verify.arg("verify-msix").arg(&bundle_path); + verify.assert().success(); + } +} + +/// Copy the unsigned bundle fixture and swap in `child` under its original entry name, +/// preserving the rest of the native MakeAppx layout. +fn repack_unsigned_bundle(dir: &tempfile::TempDir, child: &Path, bundle_name: &str) -> PathBuf { + use std::io::Write as _; + let fixture = repo_root().join(format!( + "tests/fixtures/generated-unsigned/msix/{bundle_name}" + )); + let mut archive = zip::ZipArchive::new(std::fs::File::open(&fixture).unwrap()).unwrap(); + let child_entry = archive + .file_names() + .find(|n| n.ends_with(".msix") || n.ends_with(".appx")) + .expect("child entry in bundle fixture") + .to_owned(); + let bundle_path = dir.path().join(bundle_name); + let mut writer = zip::ZipWriter::new(std::fs::File::create(&bundle_path).unwrap()); + let stored = + zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Stored); + for i in 0..archive.len() { + let mut entry = archive.by_index(i).unwrap(); + if entry.name().ends_with('/') { + continue; + } + let name = entry.name().to_owned(); + writer.start_file(&name, stored).unwrap(); + if name == child_entry { + writer.write_all(&std::fs::read(child).unwrap()).unwrap(); + } else { + std::io::copy(&mut entry, &mut writer).unwrap(); + } + } + writer.finish().unwrap(); + bundle_path +} + +#[cfg(all(feature = "timestamp-server", feature = "azure-kv-sign"))] +#[test] +fn mode_portable_sign_uses_azure_key_vault_for_msixbundle() { + let dir = tempfile::tempdir().unwrap(); + // Native AppxBundleSip requires child packages to be signed before the bundle. + let child_path = dir.path().join("sample.msix"); + std::fs::copy( + repo_root().join("tests/fixtures/generated-unsigned/msix/sample.msix"), + &child_path, + ) + .expect("copy unsigned child package fixture"); + let (_child_guard, url, certificate) = spawn_psign_azure_key_vault_server(0); + let mut child_cmd = Command::cargo_bin("psign-tool").unwrap(); + child_cmd + .arg("--mode") + .arg("portable") + .arg("sign") + .arg("--digest") + .arg("sha256") + .arg("--azure-key-vault-url") + .arg(&url) + .arg("--azure-key-vault-certificate") + .arg(&certificate) + .arg("--azure-key-vault-accesstoken") + .arg("test-token") + .arg(&child_path); + child_cmd + .assert() + .success() + .stdout(predicate::str::contains("Signed:")); + + let bundle_path = repack_unsigned_bundle(&dir, &child_path, "sample.msixbundle"); + let mut cmd = Command::cargo_bin("psign-tool").unwrap(); + cmd.arg("--mode") + .arg("portable") + .arg("sign") + .arg("--digest") + .arg("sha256") + .arg("--azure-key-vault-url") + .arg(&url) + .arg("--azure-key-vault-certificate") + .arg(&certificate) + .arg("--azure-key-vault-accesstoken") + .arg("test-token") + .arg(&bundle_path); + cmd.assert() + .success() + .stdout(predicate::str::contains("Signed:")); + + psign_sip_digest::msix_digest::verify_msix_digest_consistency(&bundle_path) + .expect("signed MSIX bundle verifies with portable AppxBundleSip digest semantics"); + + // --skip-signed must detect the freshly signed bundle as signed. + let mut skip = Command::cargo_bin("psign-tool").unwrap(); + skip.arg("--mode") + .arg("portable") + .arg("sign") + .arg("--digest") + .arg("sha256") + .arg("--skip-signed") + .arg("--azure-key-vault-url") + .arg(&url) + .arg("--azure-key-vault-certificate") + .arg(&certificate) + .arg("--azure-key-vault-accesstoken") + .arg("test-token") + .arg(&bundle_path); + skip.assert() + .success() + .stdout(predicate::str::contains("Skipped (already signed):")); +} + #[cfg(all( feature = "timestamp-server", feature = "timestamp-http", diff --git a/tests/code_command.rs b/tests/code_command.rs index 7b17ba0..7185f32 100644 --- a/tests/code_command.rs +++ b/tests/code_command.rs @@ -1368,6 +1368,140 @@ fn code_prepares_msixupload_nested_package_with_publisher_update() { .success(); } +#[test] +fn code_prepares_msixbundle_with_bundle_manifest_publisher_update() { + let temp = tempfile::tempdir().unwrap(); + let base = temp.path(); + let input = base.join("bundle.msixbundle"); + let output = base.join("prepared.msixbundle"); + let extracted_child = base.join("prepared-inner.msix"); + let nested_pe = base.join("app.signed.exe"); + let cert = base.join("signer.der"); + let key = base.join("signer.pkcs8"); + write_test_rsa_cert_key(&cert, &key); + + // A minimal flat child package with one nested PE payload. + let inner = base.join("inner.msix"); + write_zip( + &inner, + &[ + ( + "[Content_Types].xml", + br#""# + .as_slice(), + ), + ( + "AppxManifest.xml", + br#""# + .as_slice(), + ), + ( + "AppxBlockMap.xml", + br#""# + .as_slice(), + ), + ( + "app.exe", + &std::fs::read( + repo_root().join("tests/fixtures/pe-authenticode-upstream/tiny32.efi"), + ) + .unwrap(), + ), + ], + ); + write_zip( + &input, + &[ + ( + "AppxMetadata/AppxBundleManifest.xml", + br#""# + .as_slice(), + ), + ( + "AppxBlockMap.xml", + br#""# + .as_slice(), + ), + ( + "[Content_Types].xml", + br#""# + .as_slice(), + ), + ("inner.msix", &std::fs::read(&inner).unwrap()), + ], + ); + + let mut cmd = psign(); + cmd.args(["code", "--base-directory"]) + .arg(base) + .args([ + "--publisher-name", + "CN=Updated Bundle Publisher", + "--cert", + cert.to_str().unwrap(), + "--key", + key.to_str().unwrap(), + "--output", + ]) + .arg(&output) + .arg("bundle.msixbundle"); + cmd.assert() + .success() + .stdout(predicate::str::contains("unsigned MSIX/AppX")); + + // Publisher must propagate to both the bundle Identity and the child Package mirror, + // and the block map must cover only the bundle manifest (AppxBundleSip semantics). + let mut bundle_manifest = String::new(); + { + let mut archive = zip::ZipArchive::new(std::fs::File::open(&output).unwrap()).unwrap(); + archive + .by_name("AppxMetadata/AppxBundleManifest.xml") + .unwrap() + .read_to_string(&mut bundle_manifest) + .unwrap(); + let mut block_map = String::new(); + archive + .by_name("AppxBlockMap.xml") + .unwrap() + .read_to_string(&mut block_map) + .unwrap(); + assert!( + block_map.contains(r#"Name="AppxMetadata\AppxBundleManifest.xml""#), + "bundle block map must list the bundle manifest with backslash separators:\n{block_map}" + ); + assert!( + !block_map.contains("inner.msix"), + "bundle block map must not list child packages:\n{block_map}" + ); + } + assert!(bundle_manifest.contains(r#"Publisher="CN=Updated Bundle Publisher""#)); + let identity_publishers = bundle_manifest + .matches(r#"Publisher="CN=Updated Bundle Publisher""#) + .count(); + assert_eq!( + identity_publishers, 2, + "Identity@Publisher and Package@Publisher must both be updated:\n{bundle_manifest}" + ); + + // The nested flat child gets the same publisher and its PE payload is signed. + extract_zip_entry(&output, "inner.msix", &extracted_child); + let mut info = psign(); + info.args(["portable", "msix-manifest-info"]) + .arg(&extracted_child) + .assert() + .success() + .stdout(predicate::str::contains( + "publisher=CN=Updated Bundle Publisher", + )); + extract_zip_entry(&extracted_child, "app.exe", &nested_pe); + let mut verify = psign(); + verify + .args(["portable", "verify-pe"]) + .arg(&nested_pe) + .assert() + .success(); +} + #[test] fn code_classifies_encrypted_msix_as_os_only_and_fails_explicitly() { let temp = tempfile::tempdir().unwrap();