서명 서버는 개인 키를 한 곳에 보관하며, 그것이 서명 서버를 두는 이유입니다. 보통 그 대가는 파일 자체입니다. 빌드 에이전트가 설치 프로그램을 업로드하면 서버가 서명한 뒤 전체 파일을 다시 돌려보냅니다. 설치 프로그램이 수 기가바이트라면, 몇 킬로바이트의 서명을 추가하기 위해 그 설치 프로그램이 네트워크를 두 번 오가는 셈입니다.
꼭 그럴 필요는 없습니다. Authenticode가 서명하는 대상은 파일 자체가 아니라 파일의 다이제스트이며, 이 다이제스트는 파일이 이미 있는 위치에서 계산할 수 있습니다. sgcSign 2026.10부터 sgcSign Server는 EXE에 이미 그렇게 했던 것처럼, Windows Installer 패키지와 MSIX 또는 APPX 패키지도 그 다이제스트만으로 서명합니다.
sgcSign Server를 통해 1 GB 설치 프로그램을 두 번 서명한 과정으로, 한 번은 업로드로, 한 번은 해시로 서명했으며 전송된 모든 바이트를 측정했습니다. YouTube에서도 보기.
실제로 서명되는 대상
Authenticode 서명은 다이제스트를 대상으로 하며, 각 형식은 자체 다이제스트를 정의합니다. EXE의 경우 체크섬과 인증서 테이블을 제외한 PE 이미지 해시입니다. MSI의 경우 복합 파일의 스트림을 정해진 순서로 계산한 다이제스트입니다. MSIX의 경우 패키지 각 부분에 대한 SHA-256 다이제스트로 이루어진 작은 블록입니다. 서버에는 이 값만 있으면 되며, 보유한 키로 이 다이제스트에 서명하고 클라이언트가 그 서명을 파일에 기록합니다.
명령줄에서
명령줄 도구는 이미 --format authenticode에서 그랬던 것처럼 --format msi와 --format appx에서도 기본으로 해시 라우트를 사용하며, --upload를 지정하면 대신 전체 파일을 전송합니다. 이 데모는 같은 컴퓨터에 있는 서버를 대상으로, 도구가 주고받는 모든 바이트를 세는 작은 릴레이를 통해 동일한 1 GB 패키지를 두 방식으로 서명합니다. 패키지에는 1 GiB의 무작위 데이터가 들어 있어 전송 중 어떤 방식으로도 압축할 수 없습니다.
> sgcsign sign --format msi --upload --server http://127.0.0.1:18481 --apikey $k `
--provider demo --tsa http://timestamp.digicert.com --verbose `
--out BigApp-signed-upload.msi BigApp.msi
Signed: BigApp-signed-upload.msi | Signer: O=eSeGeCe Demo, CN=sgcSign Demo Code Signing | Duration: 3015 ms
> Get-Content .\relay.log -Tail 1
RUN 4: connections=1 bytes_up(client->server)=1075356521 bytes_down(server->client)=1075364108 wall=14.087s
> sgcsign sign --format msi --server http://127.0.0.1:18481 --apikey $k `
--provider demo --tsa http://timestamp.digicert.com --verbose `
--out BigApp-signed-hash.msi BigApp.msi
[prehash] alg=sha256 hash=9151f2ee34b18f80a20d4b2cec515ac344066bbed846c7efbc98d7c5e5a0cddd
> Get-Content .\relay.log -Tail 1
RUN 5: connections=1 bytes_up(client->server)=477 bytes_down(server->client)=11044 wall=0.178s
두 파일 모두 서명된 채로 돌아오며, 동일한 다이제스트를 가집니다. signtool verify /v는 두 파일 모두에서 Hash of file (sha256): 9151F2EE…5A0CDDD를 보여주는데, 이는 [prehash] 줄에 있던 값과 같으며, 데모 서명자와 DigiCert 타임스탬프도 함께 표시됩니다.
수치로 보기
| 업로드 라우트 | 해시 라우트 | |
|---|---|---|
| 패키지 | 1,075,355,648 바이트 | 1,075,355,648 바이트 |
| 서버로 전송 | 1,075,356,521 바이트 | 477 바이트 |
| 서버에서 수신 | 1,075,364,108 바이트 | 11,044 바이트 |
| 전송 시간 | 14.1 s | 0.18 s |
| 전체 명령 | 16.3 s | 4.8 s |
이 테스트는 루프백을 통해 실행되었으며, 루프백에서는 바이트를 옮기는 데 거의 비용이 들지 않는데도 업로드 라우트는 여전히 14초를 소비했습니다. 실제 네트워크에서는 전송 자체가 비용의 전부입니다. 100 Mbit/s에서 업로드 라우트의 2.15 GB는 거의 3분이 걸리지만, 해시 라우트는 여전히 477 바이트만 전송합니다. 해시 라우트의 4.8초 중 남은 시간은 빌드 에이전트에서 1 GB 패키지를 준비하고 해시하는 작업이며, 이는 업로드 라우트도 하는 작업이지만 서버에서 수행할 뿐입니다.
Delphi에서도 동일하게
Delphi 빌드 도구에서 이 흐름은 네 단계입니다. 패키지를 준비하고, 준비된 패키지를 해시하고, 해시를 전송하고, 돌아온 서명을 삽입합니다. HTTP 호출은 프로젝트에서 이미 사용 중인 어떤 클라이언트든 사용할 수 있습니다. 여기서는 RTL의 THTTPClient와 System.JSON을 사용합니다.
program HashSignMSI;
{$APPTYPE CONSOLE}
uses
System.SysUtils, System.Classes, System.JSON, System.Net.HttpClient,
sgcSign_MSI, sgcSign_Base64, sgcSign_Authenticode;
const
CS_SERVER = 'http://127.0.0.1:18480';
var
vPrepared, vSigned, vHex, vBody: string;
vHash, vPKCS7: TBytes;
vI: Integer;
oHTTP: THTTPClient;
oFields: TStringList;
oResponse: IHTTPResponse;
oJSON: TJSONValue;
begin
try
vPrepared := ChangeFileExt(ParamStr(1), '.prepared.msi');
vSigned := ChangeFileExt(ParamStr(1), '.signed.msi');
// 1. Prepare: this is the package the digest covers
sgcMSIPrepareForSigning(ParamStr(1), vPrepared);
// 2. Hash the PREPARED package on this machine
vHash := sgcMSIComputeHash(vPrepared, ahSHA256);
vHex := '';
for vI := 0 to Length(vHash) - 1 do
vHex := vHex + LowerCase(IntToHex(vHash[vI], 2));
Writeln('SHA-256 : ', vHex);
// 3. Only the digest leaves the machine
oHTTP := THTTPClient.Create;
oFields := TStringList.Create;
try
oHTTP.CustomHeaders['X-API-Key'] := GetEnvironmentVariable('SGCSIGN_APIKEY');
oFields.Add('hash=' + vHex);
oFields.Add('alg=sha256');
oFields.Add('provider=demo');
oFields.Add('tsa_url=http://timestamp.digicert.com');
oFields.Add('level=t');
oResponse := oHTTP.Post(CS_SERVER + '/api/v1/sign/msi/hash', oFields);
vBody := oResponse.ContentAsString(TEncoding.UTF8);
if oResponse.StatusCode <> 200 then
raise Exception.CreateFmt('HTTP %d: %s', [oResponse.StatusCode, vBody]);
finally
oFields.Free;
oHTTP.Free;
end;
// 4. Base64-decode the PKCS#7 and embed it into the SAME prepared package
oJSON := TJSONObject.ParseJSONValue(vBody);
try
vPKCS7 := TsgcBase64.Decode(oJSON.GetValue<string>('signature'));
finally
oJSON.Free;
end;
sgcMSIEmbedSignature(vPrepared, vSigned, vPKCS7);
Writeln('PKCS#7 : ', Length(vPKCS7), ' bytes');
Writeln('Signed : ', vSigned);
except
on E: Exception do
begin
Writeln(ErrOutput, 'error: ', E.Message);
ExitCode := 1;
end;
end;
end.
동일한 서버로부터 7,941 바이트 크기의 PKCS#7을 돌려받았으며, signtool은 위의 두 파일과 똑같이 이 결과를 읽어들입니다.
먼저 준비하고, 그다음 해시
설치 프로그램 패키지는 배포될 최종 형태로 서명되므로, 해시하기 전에 반드시 그 형태로 만들어야 합니다. sgcMSIPrepareForSigning과 sgcAppxPrepareForSigning이 이를 수행하며, 규칙은 간단합니다. prepare가 만들어 낸 결과물을 해시하고, 서명은 원본이 아니라 바로 그 준비된 패키지에 삽입해야 합니다. 원본을 해시하면 서명이 더 이상 존재하지 않는 바이트를 대상으로 하게 됩니다.
MSIX: 게시자를 직접 확인하세요
해시 라우트에서는 Windows가 엄격하게 확인하는 검사 항목의 두 절반을 어느 쪽도 함께 보지 못합니다. 서버는 인증서를 가지고 있지만 매니페스트를 보지 못하고, 클라이언트는 매니페스트를 가지고 있지만 인증서를 보지 못합니다. 그래서 모든 해시 응답에는 서명한 인증서의 주체인 signer_subject가 포함됩니다. 서명을 삽입하기 전에 sgcAppxReadPublisher와 sgcAppxDNMatches로 이를 패키지의 Publisher와 비교하십시오. 명령줄 도구는 이를 자동으로 수행하며, 값이 다르면 패키지 기록을 거부합니다.
그래도 업로드해야 할 때
전체 파일 라우트는 여전히 존재합니다. 클라이언트가 직접 다이제스트를 계산할 수 없을 때, 또는 승인 워크플로가 전체 파일 업로드에서만 동작하기 때문에 서명에 승인이 필요할 때 이를 사용하십시오. --upload를 지정하면 해당 실행에 한해 명령줄 도구를 그 라우트로 되돌립니다.
지원 범위
설치 프로그램 패키지용 해시 라우트인 /api/v1/sign/msi/hash와 /api/v1/sign/appx/hash, 명령줄 도구의 해시 우선 기본값, 그리고 prepare, hash, embed 함수는 Delphi와 C++Builder용 sgcSign 2026.10에서 제공됩니다. 해당 라우트는 sgcSign 온라인 도움말에 문서화되어 있습니다.
빌드 팜에서 대용량 패키지에 서명하고 계신가요? 문의하기를 이용하시면 실제로 코드를 작성한 사람에게서 답변을 받을 수 있습니다.
