diff --git a/docs/changes/2026-10-23-developer-signing.md b/docs/changes/2026-10-23-developer-signing.md new file mode 100644 index 0000000..c5d722e --- /dev/null +++ b/docs/changes/2026-10-23-developer-signing.md @@ -0,0 +1,53 @@ +# 개발자 서명 모드 + +2026-10-23. Rust 공통 엔진과 Tauri 서명 탭에 테스트용 자체 서명 모드를 추가했다. 여기서 개발자 서명은 유료 Microsoft 서명 서비스가 아니라 현재 사용자 저장소의 자체 서명 인증서를 사용하는 기능이다. Windows 개발자 모드와는 별개다. + +## 사용 + +1. 패키지 설정에서 Publisher를 확정한다. 예: `CN=Baron MSIX Sample`. +2. 서명 탭에서 **개발자 서명 · 테스트용 자체 서명**을 선택한다. +3. 테스트 인증서를 생성하거나 목록에서 기존 인증서를 선택한다. 생성과 선택 결과는 로컬 설정에 저장한다. +4. 검증·빌드 탭에서 Windows SDK MakeAppx.exe를 연결하고 새 계획을 생성한 뒤 빌드한다. 같은 폴더의 SignTool.exe를 사용한다. +5. 결과의 공개 CER를 확인하고 설치할 테스트 PC에서 `Install-TestCertificate.ps1`을 관리자 권한으로 실행한다. 등록 대상은 그 인증서 하나이며 `LocalMachine\TrustedPeople`에 추가한다. 제작기는 등록이나 앱 설치를 자동 실행하지 않는다. + +모드 선택 자체가 테스트용 서명 활성화다. 별도 Windows 개발자 모드나 전역 테스트 모드 토글은 요구하지 않는다. 이후 빌드에는 선택한 인증서를 재사용한다. 새 인증서는 3개월 유효하며 개인 키는 내보낼 수 없도록 생성한다. 인증서가 만료되거나 Publisher가 바뀌면 선택을 다시 해야 한다. 새 인증서를 선택하면 테스트 PC의 신뢰 등록도 별도로 필요하다. + +## 모델과 실행 + +- 프로젝트: `signing.mode = unsigned | development`; 필드가 없는 기존 프로젝트는 unsigned. +- 로컬: `developerCertificateThumbprint`; 개인 키와 PFX 비밀번호를 프로젝트에 저장하지 않는다. +- 계획: 인증서 지문·DER SHA-256·만료일과 SignTool 경로·SHA-256을 고정한다. 빌드 직전에 다시 검사한다. +- 인증서: 현재 사용자 My 저장소, 개인 키 보유, Subject/Issuer 일치, 정확한 Publisher 일치, 코드 서명 EKU, 유효 기간을 검사한다. +- 빌드: MakeAppx → SignTool SHA-256 패키지 서명 → CMS 서명과 서명자 검사 → WinVerifyTrust → 역추출 및 기존 파일·identity·registry.dat 검증. +- WinVerifyTrust 성공 또는 자체 서명 루트 미신뢰 오류 `0x800B0109`만 허용한다. 후자는 `notTrusted`로 보고한다. 다른 검증 오류는 실패 처리한다. +- 결과: `selfSignedDevelopment`, `signingReport`와 공개 CER·등록 스크립트·서명 로그. 내보내기에도 포함한다. 설치 시험은 `notRun`, 일반 배포 준비는 false로 유지한다. +- 기존 미서명 모드와 운영 서명 프로필 미지원 정책은 유지한다. 내부 EXE/DLL 개별 서명, 타임스탬프와 자동 인증서 갱신은 범위 밖이다. + +CLI 예시: + +```powershell +package-builder.exe certificate --project D:\packages\demo\package-project.json --create +package-builder.exe certificate --project D:\packages\demo\package-project.json +package-builder.exe certificate --project D:\packages\demo\package-project.json --thumbprint <40자리 지문> +``` + +CLI 사용 전 프로젝트의 signing.mode를 development로 저장한다. 인증서를 선택하지 않았거나 사용할 수 없는 경우 오류를 표시하며 미서명으로 자동 전환하지 않는다. + +## 확인 결과 + +- Rust 테스트 22개 통과. 별도 Windows SDK 통합 시험은 기존 두 제품의 미서명 빌드·내용 검사·오래된 계획 거부를 확인한다. +- 프런트엔드 테스트 8개 통과; TypeScript/Vite 빌드 통과. +- 작은 레지스트리 샘플을 이용해 기존 신뢰 인증서와 새 미신뢰 인증서로 각각 실제 서명 MSIX 생성 및 역추출 검증 성공. +- 기존 인증서: `trustVerification=passed`, `winVerifyTrust=0x00000000`. +- 새 인증서: `trustVerification=notTrusted`, `winVerifyTrust=0x800B0109`, CMS 서명 검증 성공. +- Publisher 불일치 인증서 선택 거부, 실패 시 기존 선택 보존, 인증서 재조회, 계획의 인증서 해시 변조 거부, 공개 CER·보고서 내보내기 확인. +- 이 시험에서 머신 신뢰 저장소를 변경하거나 앱을 설치하지 않았다. 새 테스트 인증서는 해당 사용자 My 저장소에 남겨 재사용할 수 있다. GUI 화면의 실제 클릭 시험은 수행하지 않았다. +- EG-BIM 대용량 샘플은 다시 생성하지 않았다. + +증빙: [시험 결과 JSON](../../src/tests/verification-2026-10-23-developer-signing.json). 샘플 위치: `output/samples/developer-signing-test-20261023`. + +## 근거 + +- [Microsoft: 패키지 서명 인증서 만들기](https://learn.microsoft.com/en-us/windows/msix/package/create-certificate-package-signing) +- [Microsoft: SignTool로 앱 패키지 서명](https://learn.microsoft.com/en-us/windows/msix/package/sign-app-package-using-signtool) +- [Microsoft: WinVerifyTrust](https://learn.microsoft.com/en-us/windows/win32/api/wintrust/nf-wintrust-winverifytrust) diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index 412c3b6..d11c2c5 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -51,6 +51,14 @@ enum Action { output: PathBuf, }, Schema, + Certificate { + #[arg(long)] + project: PathBuf, + #[arg(long, conflicts_with = "thumbprint")] + create: bool, + #[arg(long)] + thumbprint: Option, + }, } #[derive(Subcommand)] enum PlanAction { @@ -105,6 +113,22 @@ fn run(action: Action, cancel: &AtomicBool) -> Result { &std::path::absolute(output)?, )?)?, Action::Schema => print(&schemars::schema_for!(package_domain::Project))?, + Action::Certificate { + project, + create, + thumbprint, + } => print(&infra::configure_developer_certificate( + &project, + if create { + "create" + } else if thumbprint.is_some() { + "select" + } else { + "list" + }, + thumbprint.as_deref(), + cancel, + )?)?, } Ok(0) } diff --git a/src/apps/desktop/src/signing-editor.ts b/src/apps/desktop/src/signing-editor.ts new file mode 100644 index 0000000..c82527c --- /dev/null +++ b/src/apps/desktop/src/signing-editor.ts @@ -0,0 +1,16 @@ +export type DeveloperCertificate = { thumbprint: string; subject: string; notAfter: string; sha256: string; trustedPeople: boolean }; +export type SigningReport = { certificate: DeveloperCertificate; trustVerification: string; cmsSignatureValid: boolean; winVerifyTrust: string }; +const esc = (value: unknown) => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!); +export function signingStatus(signing: string, report?: SigningReport | null): string { + if (signing !== 'selfSignedDevelopment') return '미서명'; + return report?.trustVerification === 'passed' ? '개발자 서명 완료 · 현재 PC 신뢰 확인' : '개발자 서명 완료 · 테스트 PC 신뢰 등록 필요'; +} +export function renderSigningEditor(mode: string, thumbprint: string | null | undefined, certificates: DeveloperCertificate[], locked: boolean): string { + const disabled = locked ? 'disabled' : ''; + return ` + ${mode === 'development' ? `
테스트 전용

개발자 서명

Publisher와 일치하는 자체 서명 인증서로 MSIX를 서명합니다. 인증서는 현재 Windows 사용자 저장소에 보관하며 같은 인증서를 다음 빌드에도 재사용합니다.

최초 사용 시 인증서를 생성하거나 기존 인증서를 선택하세요. 생성한 인증서는 3개월간 유효하며 개인 키는 내보내지 않습니다.

+
+ +
저장된 인증서 지문${esc(thumbprint || '선택되지 않음')}
+
테스트 PC에서 설치하려면 공개 인증서를 신뢰하도록 등록해야 합니다. 빌드 결과의 development-signing.cer를 확인한 후 Install-TestCertificate.ps1을 관리자 권한으로 실행하세요. 인증서 신뢰 등록은 자동으로 수행하지 않습니다.

현재 기능은 MSIX 패키지를 서명합니다. 내부 EXE/DLL 개별 서명, 공인 서명 및 타임스탬프는 포함하지 않습니다. Windows 개발자 모드와 별개이며 일반 사용자 배포용이 아닙니다.

` : '

미서명 패키지

패키지 생성과 내용 검증만 수행합니다. 설치 테스트에는 서명이 필요합니다.

'}`; +} diff --git a/src/apps/desktop/tests/signing-editor.test.mjs b/src/apps/desktop/tests/signing-editor.test.mjs new file mode 100644 index 0000000..42b5cf0 --- /dev/null +++ b/src/apps/desktop/tests/signing-editor.test.mjs @@ -0,0 +1,20 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { renderSigningEditor, signingStatus } from '../src/signing-editor.ts'; + +test('signed packages keep trust status separate from signing', () => { + assert.equal(signingStatus('unsignedDevelopment'), '미서명'); + assert.match(signingStatus('selfSignedDevelopment', { trustVerification: 'notTrusted' }), /신뢰 등록 필요/); + assert.match(signingStatus('selfSignedDevelopment', { trustVerification: 'passed' }), /현재 PC 신뢰 확인/); + assert.match(signingStatus('selfSignedDevelopment'), /신뢰 등록 필요/); +}); + +test('certificate selection escapes certificate data and locks actions during JSON editing', () => { + const certificate = { thumbprint: 'ABC', subject: '', notAfter: '2027-01-01', trustedPeople: false }; + const html = renderSigningEditor('development', 'ABC', [certificate], true); + assert.ok(html.includes('value="ABC" selected')); + assert.ok(html.includes('<img src=x>')); + assert.ok(!html.includes('')); + assert.match(html, /data-action="certificate-create" disabled/); + assert.ok(!renderSigningEditor('unsigned', null, [], false).includes('data-action="certificate-create"')); +}); diff --git a/src/crates/package-infra/src/developer-signing.ps1 b/src/crates/package-infra/src/developer-signing.ps1 new file mode 100644 index 0000000..b62f58c --- /dev/null +++ b/src/crates/package-infra/src/developer-signing.ps1 @@ -0,0 +1,104 @@ +param([Parameter(Mandatory=$true)][string]$RequestPath) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +function NativePath([string]$Path) { + if ($Path.StartsWith('\\?\UNC\')) { return '\\' + $Path.Substring(8) } + if ($Path.StartsWith('\\?\')) { return $Path.Substring(4) } + return $Path +} +$request = Get-Content -LiteralPath (NativePath $RequestPath) -Raw -Encoding UTF8 | ConvertFrom-Json +$resultPath = NativePath $request.resultPath +$utf8 = New-Object Text.UTF8Encoding($false) +function Info($cert) { + $hash = [Security.Cryptography.SHA256]::Create() + try { $digest = ([BitConverter]::ToString($hash.ComputeHash($cert.RawData))).Replace('-','').ToLowerInvariant() } + finally { $hash.Dispose() } + return [ordered]@{ + thumbprint=$cert.Thumbprint; subject=$cert.Subject; notAfter=$cert.NotAfter.ToUniversalTime().ToString('o'); sha256=$digest + trustedPeople=(Test-Path -LiteralPath ('Cert:\LocalMachine\TrustedPeople\' + $cert.Thumbprint)) + } +} +function Eligible($cert) { + return $cert.HasPrivateKey -and $cert.Subject -ceq $request.publisher -and $cert.Subject -ceq $cert.Issuer -and ` + $cert.NotBefore -le (Get-Date) -and $cert.NotAfter -gt (Get-Date) -and ` + ('1.3.6.1.5.5.7.3.3' -in @($cert.EnhancedKeyUsageList | ForEach-Object { [string]$_.ObjectId })) +} +try { + if ($request.operation -eq 'list') { + $result = @(Get-ChildItem Cert:\CurrentUser\My | Where-Object { Eligible $_ } | ForEach-Object { Info $_ }) + } elseif ($request.operation -eq 'create') { + $cert = New-SelfSignedCertificate -Type Custom -Subject $request.publisher ` + -FriendlyName 'MSIX Studio development signing' -KeyAlgorithm RSA -KeyLength 2048 ` + -HashAlgorithm SHA256 -KeyExportPolicy NonExportable -KeyUsage DigitalSignature ` + -TextExtension @('2.5.29.37={text}1.3.6.1.5.5.7.3.3','2.5.29.19={text}') ` + -CertStoreLocation 'Cert:\CurrentUser\My' -NotAfter (Get-Date).AddMonths(3) + if (-not (Eligible $cert)) { throw 'Created certificate is incompatible with the exact package Publisher.' } + $result = Info $cert + } else { + if ($request.thumbprint -notmatch '^[A-Fa-f0-9]{40}$') { throw 'Invalid certificate thumbprint.' } + $cert = Get-Item -LiteralPath ('Cert:\CurrentUser\My\' + $request.thumbprint) + if (-not (Eligible $cert)) { throw 'Certificate must be self-issued, unexpired, code-signing enabled, have a private key, and exactly match Publisher.' } + $info = Info $cert + if ($request.operation -eq 'inspect') { $result = $info } + elseif ($request.operation -eq 'sign') { + if ($info.sha256 -cne $request.certificateSha256) { throw 'Signing certificate differs from build plan.' } + $tool = NativePath $request.tool + $package = NativePath $request.package + $output = Split-Path -Parent $package + if ((Get-AuthenticodeSignature -LiteralPath $tool).Status -ne 'Valid') { throw 'SignTool signature is not trusted.' } + & $tool sign /v /fd SHA256 /s My /sha1 $cert.Thumbprint $package > (Join-Path $output 'sign.log') + if ($LASTEXITCODE -ne 0) { throw "SignTool signing failed: $LASTEXITCODE" } + Add-Type -AssemblyName System.IO.Compression.FileSystem + Add-Type -AssemblyName System.Security + $zip = [IO.Compression.ZipFile]::OpenRead($package) + try { + $entry = $zip.GetEntry('AppxSignature.p7x') + if ($null -eq $entry) { throw 'Package signature missing.' } + $stream = $entry.Open(); $memory = New-Object IO.MemoryStream + try { $stream.CopyTo($memory); $bytes = $memory.ToArray() } finally { $stream.Dispose(); $memory.Dispose() } + } finally { $zip.Dispose() } + if ($bytes.Length -lt 5 -or [Text.Encoding]::ASCII.GetString($bytes,0,4) -cne 'PKCX') { throw 'Invalid package signature header.' } + $cms = New-Object Security.Cryptography.Pkcs.SignedCms + $cms.Decode([byte[]]$bytes[4..($bytes.Length-1)]) + $cms.CheckSignature($true) + if ($cms.SignerInfos.Count -ne 1 -or $cms.SignerInfos[0].Certificate.Thumbprint -ine $cert.Thumbprint) { throw 'Package signer mismatch.' } + Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; +public static class DevelopmentTrust { + [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)] + struct FileInfo { public uint size; [MarshalAs(UnmanagedType.LPWStr)] public string path; public IntPtr file; public IntPtr subject; } + [StructLayout(LayoutKind.Sequential)] + struct TrustData { + public uint size; public IntPtr policy; public IntPtr sip; public uint ui; public uint revocation; public uint choice; + public IntPtr info; public uint action; public IntPtr state; public IntPtr url; public uint flags; public uint context; public IntPtr settings; + } + [DllImport("wintrust.dll", ExactSpelling=true, PreserveSig=true)] + static extern int WinVerifyTrust(IntPtr window, ref Guid action, ref TrustData data); + public static uint Verify(string path) { + var file = new FileInfo { size=(uint)Marshal.SizeOf(typeof(FileInfo)), path=path }; + var pointer = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(FileInfo))); + Marshal.StructureToPtr(file,pointer,false); + var action = new Guid("00AAC56B-CD44-11d0-8CC2-00C04FC295EE"); + var data = new TrustData { size=(uint)Marshal.SizeOf(typeof(TrustData)), ui=2, choice=1, info=pointer, action=1 }; + try { return unchecked((uint)WinVerifyTrust(new IntPtr(-1),ref action,ref data)); } + finally { data.action=2; WinVerifyTrust(new IntPtr(-1),ref action,ref data); Marshal.DestroyStructure(pointer,typeof(FileInfo)); Marshal.FreeHGlobal(pointer); } + } +} +'@ + $trust = [DevelopmentTrust]::Verify($package) + # Only a successful trust check or an untrusted self-signed root is allowed. + # CMS validation and post-sign package unpack/hash inspection are separate gates. + if ($trust -ne 0 -and $trust -ne [uint32]2148204809) { throw ('Package verification failed: 0x{0:X8}' -f $trust) } + Export-Certificate -Cert $cert -FilePath (Join-Path $output 'development-signing.cer') -Type CERT | Out-Null + $result = [ordered]@{ mode='development'; certificate=$info; cmsSignatureValid=$true; + trustVerification=$(if ($trust -eq 0) { 'passed' } else { 'notTrusted' }); + winVerifyTrust=('0x{0:X8}' -f $trust); timestamped=$false } + } else { throw 'Unknown signing operation.' } + } + $json = ConvertTo-Json -InputObject $result -Depth 10 + [IO.File]::WriteAllText($resultPath, $json, $utf8) +} catch { + [IO.File]::WriteAllText($resultPath, (ConvertTo-Json @{error=$_.Exception.Message}), $utf8) + exit 1 +} diff --git a/src/crates/package-infra/src/lib.rs b/src/crates/package-infra/src/lib.rs index a8705c0..a54ba82 100644 --- a/src/crates/package-infra/src/lib.rs +++ b/src/crates/package-infra/src/lib.rs @@ -1,6 +1,8 @@ mod files; mod process; mod registry; +mod signing; +pub use signing::{configure_developer_certificate, CertificateSettings}; mod store; pub use store::*; @@ -18,10 +20,9 @@ impl PackageBackend for MsixBackend { fn signing_plan( &self, project: &Project, - _local: &LocalConfig, + local: &LocalConfig, ) -> Result> { - ensure!(project.signing.mode == package_domain::SigningMode::Unsigned, "UnsupportedSigning: developer signing is not enabled yet"); - Ok(None) + signing::plan(project, local) } fn resolve( &self, @@ -102,6 +103,14 @@ impl PackageBackend for MsixBackend { &work.join("pack.log"), cancel, )?; + ensure!( + (plan.project.signing.mode == package_domain::SigningMode::Development) + == plan.development_signing.is_some(), + "SigningPlanMismatch" + ); + if let Some(signing) = &plan.development_signing { + signing::sign(signing, &plan.project, &package, work, cancel)?; + } let unpack = work.join("inspection"); process::run( &plan.tool.path, diff --git a/src/crates/package-infra/src/signing.rs b/src/crates/package-infra/src/signing.rs new file mode 100644 index 0000000..7cda1ed --- /dev/null +++ b/src/crates/package-infra/src/signing.rs @@ -0,0 +1,181 @@ +use anyhow::{ensure, Context, Result}; +use package_domain::{LocalConfig, Project, SigningMode}; +use package_engine::{DeveloperCertificate, DevelopmentSigningPlan, SigningReport, Tool}; +use serde::{de::DeserializeOwned, Serialize}; +use serde_json::{json, Value}; +use std::{fs, path::Path, sync::atomic::AtomicBool}; + +fn request(mut value: Value, cancel: &AtomicBool) -> Result { + ensure!( + cfg!(windows), + "SigningPlatform: developer signing requires Windows" + ); + let temp = tempfile::tempdir()?; + let script = temp.path().join("developer-signing.ps1"); + let input = temp.path().join("request.json"); + let output = temp.path().join("result.json"); + value["resultPath"] = json!(output); + fs::write(&script, include_bytes!("developer-signing.ps1"))?; + super::atomic_json(&input, &value)?; + let powershell = std::path::PathBuf::from( + std::env::var_os("SystemRoot").context("MissingWindowsDirectory")?, + ) + .join("System32/WindowsPowerShell/v1.0/powershell.exe"); + let operation = super::process::run( + &powershell, + &[ + "-NoProfile".as_ref(), + "-NonInteractive".as_ref(), + "-File".as_ref(), + script.as_os_str(), + "-RequestPath".as_ref(), + input.as_os_str(), + ], + &temp.path().join("operation.log"), + cancel, + ); + if let Err(error) = operation { + if output.is_file() { + let report: Value = serde_json::from_slice(&fs::read(&output)?)?; + if let Some(message) = report["error"].as_str() { + anyhow::bail!("DeveloperSigning: {message}"); + } + } + return Err(error); + } + Ok(serde_json::from_slice(&fs::read(output)?)?) +} +fn publisher(project: &Project) -> Result<&str> { + let value = project + .formats + .msix + .identity + .publisher + .as_deref() + .context("MissingPublisher: enter the certificate Subject first")?; + ensure!( + value.starts_with("CN=") && value.len() > 3 && !value.contains('\0'), + "InvalidPublisher" + ); + Ok(value) +} +pub fn inspect( + project: &Project, + thumbprint: &str, + cancel: &AtomicBool, +) -> Result { + ensure!( + thumbprint.len() == 40 && thumbprint.bytes().all(|c| c.is_ascii_hexdigit()), + "InvalidCertificateThumbprint" + ); + request( + json!({"operation":"inspect", "publisher":publisher(project)?, "thumbprint":thumbprint}), + cancel, + ) +} +pub fn plan(project: &Project, local: &LocalConfig) -> Result> { + if project.signing.mode == SigningMode::Unsigned { + return Ok(None); + } + let certificate = inspect( + project, + local.developer_certificate_thumbprint.as_deref().context( + "MissingDeveloperCertificate: 서명 탭에서 테스트 인증서를 선택하거나 생성하세요.", + )?, + &AtomicBool::new(false), + )?; + let path = Path::new( + local + .make_appx + .as_deref() + .context("MissingTool: select Windows SDK MakeAppx.exe")?, + ) + .with_file_name("signtool.exe"); + ensure!( + path.is_absolute() && path.is_file(), + "MissingSignTool: Windows SDK MakeAppx.exe와 같은 폴더의 SignTool.exe가 필요합니다." + ); + let tool = Tool { + path: path.canonicalize()?, + sha256: super::hash_file(&path)?, + }; + Ok(Some(DevelopmentSigningPlan { tool, certificate })) +} +pub fn sign( + plan: &DevelopmentSigningPlan, + project: &Project, + package: &Path, + work: &Path, + cancel: &AtomicBool, +) -> Result<()> { + ensure!( + super::hash_file(&plan.tool.path)? == plan.tool.sha256, + "ToolchainMismatch: SignTool changed" + ); + let report: SigningReport = request( + json!({"operation":"sign", "publisher":publisher(project)?, "thumbprint":plan.certificate.thumbprint, "certificateSha256":plan.certificate.sha256, "tool":plan.tool.path, "package":package}), + cancel, + )?; + ensure!( + report.certificate.sha256 == plan.certificate.sha256 && report.cms_signature_valid, + "SignatureMismatch" + ); + super::atomic_json(&work.join("signing-report.json"), &report)?; + // No automatic machine trust changes; export a narrowly scoped administrator script. + let trust_script = format!( + r#"#Requires -RunAsAdministrator +$ErrorActionPreference = 'Stop' +$file = Join-Path $PSScriptRoot 'development-signing.cer' +$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 $file +if ($cert.Thumbprint -cne '{}') {{ throw 'Unexpected certificate' }} +Import-Certificate -FilePath $file -CertStoreLocation Cert:\LocalMachine\TrustedPeople +"#, + report.certificate.thumbprint + ); + fs::write(work.join("Install-TestCertificate.ps1"), trust_script)?; + Ok(()) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CertificateSettings { + pub document: super::ProjectDocument, + pub certificates: Vec, +} +pub fn configure_developer_certificate( + path: &Path, + action: &str, + thumbprint: Option<&str>, + cancel: &AtomicBool, +) -> Result { + let mut doc = super::open_project(path)?; + ensure!( + doc.project.signing.mode == SigningMode::Development, + "DevelopmentModeRequired: 개발자 서명 모드를 먼저 저장하세요." + ); + let selected = match action { + "list" => None, + "create" => Some(request::( + json!({"operation":"create", "publisher":publisher(&doc.project)?}), + cancel, + )?), + "select" => Some(inspect( + &doc.project, + thumbprint.context("MissingCertificateThumbprint")?, + cancel, + )?), + _ => anyhow::bail!("UnsupportedCertificateAction"), + }; + if let Some(cert) = selected { + doc.local.developer_certificate_thumbprint = Some(cert.thumbprint); + doc = super::save_project(&doc)?; + } + let certificates = request( + json!({"operation":"list", "publisher":publisher(&doc.project)?}), + cancel, + )?; + Ok(CertificateSettings { + document: doc, + certificates, + }) +} diff --git a/src/crates/package-infra/tests/workflow.rs b/src/crates/package-infra/tests/workflow.rs new file mode 100644 index 0000000..b9b4e10 --- /dev/null +++ b/src/crates/package-infra/tests/workflow.rs @@ -0,0 +1,313 @@ +use package_domain::Project; +use package_engine::PackageBackend; +use package_infra::{self as infra, MsixBackend}; +use std::{ + fs, + path::{Path, PathBuf}, + sync::atomic::AtomicBool, +}; + +fn png(path: &Path, size: u32) { + let file = fs::File::create(path).unwrap(); + let mut encoder = png::Encoder::new(file, size, size); + encoder.set_color(png::ColorType::Rgba); + let mut writer = encoder.write_header().unwrap(); + writer + .write_image_data(&[50, 100, 220, 255].repeat((size * size) as usize)) + .unwrap(); +} +fn fixture() -> (tempfile::TempDir, PathBuf) { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("package-project.json"); + let mut doc = infra::create_project(&path).unwrap(); + let payload = temp.path().join("payload"); + fs::create_dir_all(payload.join("Assets")).unwrap(); + let mut pe = vec![0; 256]; + pe[..2].copy_from_slice(b"MZ"); + pe[60] = 64; + pe[64..68].copy_from_slice(b"PE\0\0"); + pe[68..70].copy_from_slice(&0x8664u16.to_le_bytes()); + fs::write(payload.join("SampleTool.exe"), pe).unwrap(); + png(&payload.join("Assets/Logo44.png"), 44); + png(&payload.join("Assets/Logo150.png"), 150); + fs::write(payload.join("ignore.pdb"), "debug").unwrap(); + doc.project.files[0].exclude = vec!["**/*.pdb".into()]; + doc.local + .sources + .insert("appPayload".into(), "payload".into()); + infra::save_project(&doc).unwrap(); + (temp, path) +} +#[test] +fn deterministic_resolution_and_exclusions() { + let (_temp, path) = fixture(); + let doc = infra::open_project(&path).unwrap(); + let first = MsixBackend + .resolve(&doc.project, &doc.local, doc.path.parent().unwrap()) + .unwrap(); + let second = MsixBackend + .resolve(&doc.project, &doc.local, doc.path.parent().unwrap()) + .unwrap(); + assert_eq!(first, second); + assert_eq!(first.len(), 3); + assert_eq!(first[0].target, "Assets/Logo150.png"); +} + +#[test] +fn signing_defaults_and_local_selection_are_backward_compatible() { + use package_domain::SigningMode; + let (_temp, path) = fixture(); + let mut json: serde_json::Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + json.as_object_mut().unwrap().remove("signing"); + fs::write(&path, serde_json::to_vec_pretty(&json).unwrap()).unwrap(); + let mut doc = infra::open_project(&path).unwrap(); + assert_eq!(doc.project.signing.mode, SigningMode::Unsigned); + assert!(doc.local.developer_certificate_thumbprint.is_none()); + doc.project.signing.mode = SigningMode::Development; + doc.local.developer_certificate_thumbprint = Some("A".repeat(40)); + let saved = infra::save_project(&doc).unwrap(); + assert_eq!(saved.project.signing.mode, SigningMode::Development); + assert_eq!( + saved.local.developer_certificate_thumbprint, + Some("A".repeat(40)) + ); + assert!(!fs::read_to_string(&path).unwrap().contains(&"A".repeat(40))); + json["signing"] = serde_json::json!({"mode":"production"}); + assert!(serde_json::from_value::(json).is_err()); +} + +#[test] +fn development_signing_requires_explicit_certificate_without_unsigned_fallback() { + use package_domain::SigningMode; + let (_temp, path) = fixture(); + let mut doc = infra::open_project(&path).unwrap(); + assert!(MsixBackend + .signing_plan(&doc.project, &doc.local) + .unwrap() + .is_none()); + doc.project.signing.mode = SigningMode::Development; + assert!(MsixBackend + .signing_plan(&doc.project, &doc.local) + .unwrap_err() + .to_string() + .contains("MissingDeveloperCertificate")); + doc.local.developer_certificate_thumbprint = Some("not-a-thumbprint".into()); + assert!(MsixBackend + .signing_plan(&doc.project, &doc.local) + .unwrap_err() + .to_string() + .contains("InvalidCertificateThumbprint")); +} +#[test] +fn save_rejects_stale_revision_and_preserves_backup() { + let (_temp, path) = fixture(); + let mut stale = infra::open_project(&path).unwrap(); + let mut edited = stale.clone(); + edited.project.product.display_name = "new name".into(); + infra::save_project(&edited).unwrap(); + stale.project.product.display_name = "old editor".into(); + assert!(infra::save_project(&stale) + .unwrap_err() + .to_string() + .contains("RevisionConflict")); + let backup: Project = + serde_json::from_slice(&fs::read(infra::appended_path(&path, ".bak")).unwrap()).unwrap(); + assert_ne!(backup.product.display_name, "new name"); +} +#[test] +fn collision_missing_asset_and_bad_pe_are_rejected() { + let (temp, path) = fixture(); + let mut doc = infra::open_project(&path).unwrap(); + let mut duplicate = doc.project.files[0].clone(); + duplicate.id = "other".into(); + doc.project.files.push(duplicate); + assert!(MsixBackend + .resolve(&doc.project, &doc.local, doc.path.parent().unwrap()) + .unwrap_err() + .to_string() + .contains("Collision")); + doc.project.files.pop(); + png(&temp.path().join("payload/Assets/Logo44.png"), 45); + assert!(MsixBackend + .resolve(&doc.project, &doc.local, doc.path.parent().unwrap()) + .unwrap_err() + .to_string() + .contains("InvalidAssetSize")); + fs::write( + temp.path().join("payload/SampleTool.exe"), + "not an executable", + ) + .unwrap(); + assert!(MsixBackend + .resolve(&doc.project, &doc.local, doc.path.parent().unwrap()) + .unwrap_err() + .to_string() + .contains("InvalidPE")); +} +#[test] +fn source_may_not_contain_work_or_release_outputs() { + let (_temp, path) = fixture(); + let mut doc = infra::open_project(&path).unwrap(); + doc.local.sources.insert("appPayload".into(), ".".into()); + assert!(MsixBackend + .resolve(&doc.project, &doc.local, doc.path.parent().unwrap()) + .unwrap_err() + .to_string() + .contains("OutputInsideSource")); +} +#[test] +fn project_lock_blocks_concurrent_writers() { + let (_temp, path) = fixture(); + let doc = infra::open_project(&path).unwrap(); + let _lock = infra::lock_project(path.parent().unwrap()).unwrap(); + assert!(infra::save_project(&doc) + .unwrap_err() + .to_string() + .contains("JobLocked")); +} + +#[test] +fn registry_edits_round_trip_and_old_projects_default_to_empty() { + let (_temp, path) = fixture(); + let mut value: serde_json::Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + value.as_object_mut().unwrap().remove("registry"); + fs::write(&path, serde_json::to_vec(&value).unwrap()).unwrap(); + let mut doc = infra::open_project(&path).unwrap(); + assert!(doc.project.registry.is_empty()); + doc.project.registry = serde_json::from_value(serde_json::json!([ + {"id":"max", "hive":"HKLM", "key":"Software\\Baron", "name":"Maximum", "view":"64", "valueType":"REG_QWORD", "data":"18446744073709551615"} + ])).unwrap(); + let mut saved = infra::save_project(&doc).unwrap(); + assert_eq!(saved.project.registry[0].data, "18446744073709551615"); + assert_ne!(saved.revision, doc.revision); + saved.project.registry.clear(); + infra::save_project(&saved).unwrap(); + assert!(infra::open_project(&path) + .unwrap() + .project + .registry + .is_empty()); +} + +#[test] +fn source_cannot_override_generated_registry_hives() { + for relative in [ + "Registry.dat", + "registry.dat/nested.txt", + "User.dat", + "User.Classes.dat", + ] { + let (temp, path) = fixture(); + let file = temp.path().join("payload").join(relative); + fs::create_dir_all(file.parent().unwrap()).unwrap(); + fs::write(&file, "untrusted hive").unwrap(); + let doc = infra::open_project(&path).unwrap(); + assert!(MsixBackend + .resolve(&doc.project, &doc.local, path.parent().unwrap()) + .unwrap_err() + .to_string() + .contains("ReservedPath")); + } +} + +#[test] +fn opening_unsupported_schema_does_not_modify_original() { + let (_temp, path) = fixture(); + let mut value: serde_json::Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + value["schemaVersion"] = serde_json::json!(1); + let original = serde_json::to_vec(&value).unwrap(); + fs::write(&path, &original).unwrap(); + assert!(infra::open_project(&path) + .unwrap_err() + .to_string() + .contains("UnsupportedSchema")); + assert_eq!(fs::read(path).unwrap(), original); +} + +#[test] +fn schema_file_matches_rust_contract() { + // The CLI emits this same schema; this catches a stale checked-in schema. + let actual = serde_json::to_value(schemars::schema_for!(Project)).unwrap(); + let expected: serde_json::Value = + serde_json::from_str(include_str!("../../../schemas/package-project.schema.json")).unwrap(); + assert_eq!(actual, expected); +} + +#[test] +#[ignore = "Requires a real Windows SDK: set MSIX_TEST_MAKEAPPX and run --ignored"] +fn real_sdk_packages_two_products_and_rejects_stale_plans() { + let tool = std::env::var("MSIX_TEST_MAKEAPPX").expect("MSIX_TEST_MAKEAPPX is required"); + for name in ["FixtureAlpha", "FixtureBeta"] { + let (temp, path) = fixture(); + let mut doc = infra::open_project(&path).unwrap(); + doc.project.formats.msix.identity.name = format!("ExamplePublisher.{name}"); + doc.project.product.display_name = name.into(); + doc.local.make_appx = Some(tool.clone()); + doc.project.registry = serde_json::from_value(serde_json::json!([ + {"id":"caption", "hive":"HKLM", "key":"Software\\MSIXStudioTest", "name":"", "view":"64", "valueType":"REG_SZ", "data":"한글 기본값"}, + {"id":"mode", "hive":"HKLM", "key":"Software\\MSIXStudioTest", "name":"Mode", "view":"32", "valueType":"REG_DWORD", "data":"0xFFFFFFFF"} + ])).unwrap(); + // A real compiler-produced PE; this checks packaging, not app installation or launch. + fs::copy( + std::env::current_exe().unwrap(), + temp.path().join("payload/SampleTool.exe"), + ) + .unwrap(); + infra::save_project(&doc).unwrap(); + let plan = infra::plan_release(&path, "msix-direct").unwrap(); + fs::write(temp.path().join("payload/changed.txt"), "changed").unwrap(); + assert!(infra::build(&path, &plan.id, &AtomicBool::new(false)) + .unwrap_err() + .to_string() + .contains("StalePlan")); + let plan = infra::plan_release(&path, "msix-direct").unwrap(); + let result = infra::build(&path, &plan.id, &AtomicBool::new(false)).unwrap(); + let registry_report: serde_json::Value = serde_json::from_slice( + &fs::read( + result + .artifact + .parent() + .unwrap() + .join("registry-report.json"), + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(registry_report["entryCount"], 2); + assert_eq!(registry_report["status"], "verified"); + assert!(temp + .path() + .join(".work/jobs") + .join(&plan.id) + .join("inspection/registry.dat") + .is_file()); + assert_eq!(result.status, "ArtifactVerified"); + assert!(!result.deployment_ready); + assert_eq!(result.installation_test, "notRun"); + assert_eq!(infra::hash_file(&result.artifact).unwrap(), result.sha256); + assert!(infra::build(&path, &plan.id, &AtomicBool::new(false)) + .unwrap_err() + .to_string() + .contains("DuplicateBuild")); + let export = temp.path().join("exported"); + infra::export_release(&path, &plan.id, &export).unwrap(); + assert!(export.join("registry-report.json").is_file()); + assert!(infra::export_release(&path, &plan.id, &export).is_err()); + fs::write(result.artifact, "tamper").unwrap(); + assert!( + infra::export_release(&path, &plan.id, &temp.path().join("tampered")) + .unwrap_err() + .to_string() + .contains("ArtifactMismatch") + ); + let cancel_plan = infra::plan_release(&path, "msix-direct").unwrap(); + assert!(infra::build(&path, &cancel_plan.id, &AtomicBool::new(true)) + .unwrap_err() + .to_string() + .contains("Cancelled")); + assert_eq!( + infra::job_status(&path, &cancel_plan.id).unwrap().status, + "Cancelled" + ); + } +} diff --git a/src/scripts/Sign-DevelopmentMsix.ps1 b/src/scripts/Sign-DevelopmentMsix.ps1 new file mode 100644 index 0000000..f5b80b9 --- /dev/null +++ b/src/scripts/Sign-DevelopmentMsix.ps1 @@ -0,0 +1,125 @@ +param( + [Parameter(Mandatory = $true)][string]$Release, + [Parameter(Mandatory = $true)][string]$Output, + [string]$SignTool = 'C:\Program Files (x86)\Windows Kits\10\bin\10.0.26100.0\x64\signtool.exe', + [string]$CertificateThumbprint +) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +Add-Type -AssemblyName System.IO.Compression.FileSystem +Add-Type -AssemblyName System.Security +$utf8 = New-Object System.Text.UTF8Encoding($false) +$releasePath = (Resolve-Path -LiteralPath $Release).Path +$record = Get-Content -LiteralPath $releasePath -Raw -Encoding UTF8 | ConvertFrom-Json +$source = Join-Path (Split-Path -Parent $releasePath) 'package.msix' +Write-Host 'Checking original release hash...' +if ($record.status -ne 'ArtifactVerified' -or $record.signing -ne 'unsignedDevelopment') { + throw 'Expected a verified unsigned release from package-builder.' +} +if ((Get-FileHash -LiteralPath $source -Algorithm SHA256).Hash -ine $record.sha256) { + throw 'Original package does not match release.json.' +} +$tool = (Resolve-Path -LiteralPath $SignTool).Path +if ([IO.Path]::GetFileName($tool) -ine 'signtool.exe') { throw 'Expected Windows SDK SignTool.exe.' } +if ((Get-AuthenticodeSignature -LiteralPath $tool).Status -ne 'Valid') { + throw 'SignTool must have a valid Authenticode signature.' +} +$outputPath = [IO.Path]::GetFullPath($Output) +if (Test-Path -LiteralPath $outputPath) { throw "Output already exists: $outputPath" } +$zip = [IO.Compression.ZipFile]::OpenRead($source) +try { + if ($null -ne $zip.GetEntry('AppxSignature.p7x')) { throw 'Input is already signed.' } + $entry = $zip.GetEntry('AppxManifest.xml') + if ($null -eq $entry) { throw 'Package manifest missing.' } + $reader = New-Object IO.StreamReader($entry.Open()) + try { [xml]$manifest = $reader.ReadToEnd() } finally { $reader.Dispose() } + $publisher = [string]$manifest.Package.Identity.Publisher + if ([string]::IsNullOrWhiteSpace($publisher)) { throw 'Publisher missing.' } +} finally { $zip.Dispose() } + +New-Item -ItemType Directory -Path $outputPath | Out-Null +$signedPackage = Join-Path $outputPath 'package.msix' +Write-Host 'Copying original release to a separate signing output...' +$inputStream = [IO.File]::OpenRead($source) +try { + $outputStream = [IO.File]::Open($signedPackage, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write) + try { $inputStream.CopyTo($outputStream, 1048576) } finally { $outputStream.Dispose() } +} finally { $inputStream.Dispose() } +Write-Host 'Checking copied package hash...' +if ((Get-FileHash -LiteralPath $signedPackage -Algorithm SHA256).Hash -ine $record.sha256) { + throw 'Copied package hash mismatch.' +} +if ($CertificateThumbprint) { + if ($CertificateThumbprint -notmatch '^[A-Fa-f0-9]{40}$') { throw 'Invalid certificate thumbprint.' } + $certificate = Get-Item -LiteralPath "Cert:\CurrentUser\My\$CertificateThumbprint" +} else { + # Private key stays non-exportable in the current user's Personal store. + # This creates a signing identity; it does NOT grant machine trust. + $certificate = New-SelfSignedCertificate -Type Custom -Subject $publisher ` + -FriendlyName 'MSIX Studio local development signing' ` + -KeyAlgorithm RSA -KeyLength 2048 -HashAlgorithm SHA256 ` + -KeyExportPolicy NonExportable -KeyUsage DigitalSignature ` + -TextExtension @('2.5.29.37={text}1.3.6.1.5.5.7.3.3', '2.5.29.19={text}') ` + -CertStoreLocation 'Cert:\CurrentUser\My' -NotAfter (Get-Date).AddMonths(3) +} +if ($certificate.Subject -cne $publisher -or -not $certificate.HasPrivateKey) { + throw 'Certificate subject must match Publisher and have a private key.' +} +if ($certificate.NotBefore -gt (Get-Date) -or $certificate.NotAfter -le (Get-Date)) { + throw 'Certificate is outside its validity period.' +} +if ('1.3.6.1.5.5.7.3.3' -notin @($certificate.EnhancedKeyUsageList | ForEach-Object { [string]$_.ObjectId })) { + throw 'Certificate must have the code signing EKU.' +} +$cerPath = Join-Path $outputPath 'development-signing.cer' +Export-Certificate -Cert $certificate -FilePath $cerPath -Type CERT | Out-Null +Write-Host 'Signing package with the selected development certificate...' +& $tool sign /v /fd SHA256 /s My /sha1 $certificate.Thumbprint $signedPackage > (Join-Path $outputPath 'sign.log') +if ($LASTEXITCODE -ne 0) { throw "SignTool failed ($LASTEXITCODE). See sign.log." } + +# Check CMS integrity without pretending that a self-signed certificate is trusted. +$zip = [IO.Compression.ZipFile]::OpenRead($signedPackage) +try { + $entry = $zip.GetEntry('AppxSignature.p7x') + if ($null -eq $entry) { throw 'Signed package has no signature part.' } + $stream = $entry.Open() + $buffer = New-Object IO.MemoryStream + try { $stream.CopyTo($buffer); $signatureBytes = $buffer.ToArray() } + finally { $stream.Dispose(); $buffer.Dispose() } +} finally { $zip.Dispose() } +if ([Text.Encoding]::ASCII.GetString($signatureBytes, 0, 4) -cne 'PKCX') { throw 'Invalid signature header.' } +$cms = New-Object Security.Cryptography.Pkcs.SignedCms +$cms.Decode([byte[]]$signatureBytes[4..($signatureBytes.Length - 1)]) +$cms.CheckSignature($true) +if ($cms.SignerInfos.Count -ne 1 -or $cms.SignerInfos[0].Certificate.Thumbprint -ine $certificate.Thumbprint) { + throw 'Unexpected package signer.' +} +# Native verify can fail until the test PC explicitly trusts this public certificate. +Write-Host 'Checking package signature and certificate trust...' +try { + $ErrorActionPreference = 'Continue' + $verification = & $tool verify /pa /v $signedPackage 2>&1 + $verifyExitCode = $LASTEXITCODE +} finally { $ErrorActionPreference = 'Stop' } +[IO.File]::WriteAllText((Join-Path $outputPath 'verify.log'), ($verification -join "`r`n"), $utf8) +$report = [ordered]@{ + sourceRelease = $releasePath + sourceBuildId = $record.buildId + sourceSha256 = $record.sha256 + artifact = $signedPackage + sha256 = (Get-FileHash -LiteralPath $signedPackage -Algorithm SHA256).Hash.ToLowerInvariant() + signing = 'selfSignedDevelopment' + certificateSubject = $certificate.Subject + certificateThumbprint = $certificate.Thumbprint + certificateNotAfter = $certificate.NotAfter.ToUniversalTime().ToString('o') + publicCertificate = $cerPath + cmsCryptographicSignatureValid = $true + signToolVerifyExitCode = $verifyExitCode + trustVerification = $(if ($verifyExitCode -eq 0) { 'passed' } else { 'notPassedSeeVerifyLog' }) + certificateTrustChanged = $false + timestamped = $false + installationTest = 'notRun' + deploymentReady = $false +} +[IO.File]::WriteAllText((Join-Path $outputPath 'signing-result.json'), ($report | ConvertTo-Json -Depth 5), $utf8) +$report | ConvertTo-Json -Depth 5 diff --git a/src/tests/verification-2026-10-23-developer-signing.json b/src/tests/verification-2026-10-23-developer-signing.json new file mode 100644 index 0000000..4459bb4 --- /dev/null +++ b/src/tests/verification-2026-10-23-developer-signing.json @@ -0,0 +1,62 @@ +{ + "date": "2026-10-23", + "checks": { + "wrongPublisherRejected": true, + "failedSelectionPreservedLocal": true, + "generatedCertificateReusable": true, + "changedSigningPlanRejected": true, + "exportSucceeded": true, + "publicCertificateAndReportExported": true, + "noPrivateKeyExported": true + }, + "builds": { + "trusted": { + "buildId": "d1cf067b-da92-49b4-b7f4-2677b603f541", + "status": "ArtifactVerified", + "artifact": "\\\\?\\D:\\networkSetup\\output\\samples\\developer-signing-test-20261023\\trusted\\releases\\d1cf067b-da92-49b4-b7f4-2677b603f541\\package.msix", + "sha256": "9fd21139abcac59c0871c5f0c48dbf7863111aaae6c41e68e23d4d8e09029ed6", + "signing": "selfSignedDevelopment", + "signingReport": { + "mode": "development", + "certificate": { + "thumbprint": "E0444310862C4BF5802FD36AB4AE2DD3F7D12F05", + "subject": "CN=Baron MSIX Sample", + "notAfter": "2027-01-22T06:50:26.0000000Z", + "sha256": "c3e07125a9dba6896006fbc44fdf51c329f42aefff8f89161c2d51b06d8ec451", + "trustedPeople": true + }, + "cmsSignatureValid": true, + "trustVerification": "passed", + "winVerifyTrust": "0x00000000", + "timestamped": false + }, + "installationTest": "notRun", + "deploymentReady": false + }, + "untrusted": { + "buildId": "444c2950-7d45-4fd5-8bc9-980256c1670d", + "status": "ArtifactVerified", + "artifact": "\\\\?\\D:\\networkSetup\\output\\samples\\developer-signing-test-20261023\\untrusted\\releases\\444c2950-7d45-4fd5-8bc9-980256c1670d\\package.msix", + "sha256": "2503006dfab7931807bb3489e3cc4d094397bdc622b11be83d207c012fc45a53", + "signing": "selfSignedDevelopment", + "signingReport": { + "mode": "development", + "certificate": { + "thumbprint": "B0B41B54EB26C42B525123135DF5E66C5F977722", + "subject": "CN=MSIX Studio Signing Test 20261023", + "notAfter": "2027-01-23T01:32:53.0000000Z", + "sha256": "07b611a261d3ac43ea1206cda6aaf32eff6568bb77c6353127c901bee84904f9", + "trustedPeople": false + }, + "cmsSignatureValid": true, + "trustVerification": "notTrusted", + "winVerifyTrust": "0x800B0109", + "timestamped": false + }, + "installationTest": "notRun", + "deploymentReady": false + } + }, + "installationTest": "notRun", + "machineTrustModified": false +} \ No newline at end of file