2
0
Эх сурвалжийг харах

Merge pull request #508 from jrsoftware/issig

Merge "issig" branch
Jordan Russell 5 сар өмнө
parent
commit
0577d85b44

+ 409 - 0
Components/ECDSA.pas

@@ -0,0 +1,409 @@
+unit ECDSA;
+
+{
+  Inno Setup
+  Copyright (C) 1997-2025 Jordan Russell
+  Portions by Martijn Laan
+  For conditions of distribution and use, see LICENSE.TXT.
+
+  ECDSA-P256 signing, verification, and key generation, based on CNG (BCrypt)
+}
+
+interface
+
+uses
+  Windows, SysUtils;
+
+type
+  TECDSAInt256 = array[0..31] of Byte;
+  TECDSAPublicKey = packed record
+    Public_x: TECDSAInt256;
+    Public_y: TECDSAInt256;
+    procedure Clear;
+  end;
+  TECDSAPrivateKey = packed record
+    PublicKey: TECDSAPublicKey;
+    Private_d: TECDSAInt256;
+    procedure Clear;
+  end;
+  TECDSASignature = packed record
+    Sig_r: TECDSAInt256;
+    Sig_s: TECDSAInt256;
+    procedure Clear;
+  end;
+
+  TECDSAKey = class
+  private
+    FAlgorithmHandle: THandle;  { BCRYPT_ALG_HANDLE }
+    FKeyHandle: THandle;        { BCRYPT_KEY_HANDLE }
+    class procedure CheckStatus(const AFunctionName: String;
+      const AStatus: NTSTATUS); static;
+    procedure KeyHandleRequired;
+  public
+    constructor Create;
+    destructor Destroy; override;
+    procedure DestroyKey;
+    procedure ExportPrivateKey(out APrivateKey: TECDSAPrivateKey);
+    procedure ExportPublicKey(out APublicKey: TECDSAPublicKey);
+    procedure GenerateKeyPair;
+    procedure ImportPrivateKey([ref] const APrivateKey: TECDSAPrivateKey);
+    procedure ImportPublicKey([ref] const APublicKey: TECDSAPublicKey);
+    procedure SignHash(const AHash: array of Byte;
+      out ASignature: TECDSASignature);
+    function VerifySignature(const AHash: array of Byte;
+      const ASignature: TECDSASignature): Boolean;
+  end;
+
+  EECDSAError = class(Exception);
+
+implementation
+
+type
+  BCRYPT_ALG_HANDLE = type THandle;
+  BCRYPT_KEY_HANDLE = type THandle;
+
+  BCRYPT_ECCKEY_BLOB = record
+    dwMagic: ULONG;
+    cbKey: ULONG;
+  end;
+
+const
+  BCRYPT_ECDSA_P256_ALGORITHM = 'ECDSA_P256';
+  BCRYPT_ECCPRIVATE_BLOB = 'ECCPRIVATEBLOB';
+  BCRYPT_ECCPUBLIC_BLOB = 'ECCPUBLICBLOB';
+  BCRYPT_ECDSA_PRIVATE_P256_MAGIC = $32534345;
+  BCRYPT_ECDSA_PUBLIC_P256_MAGIC = $31534345;
+
+  STATUS_INVALID_SIGNATURE = NTSTATUS($C000A000);
+
+var
+  BCryptFunctionsInitialized: BOOL;
+
+  BCryptCloseAlgorithmProvider: function(hAlgorithm: BCRYPT_ALG_HANDLE;
+    dwFlags: ULONG): NTSTATUS;
+    stdcall;
+
+  BCryptDestroyKey: function(hKey: BCRYPT_KEY_HANDLE): NTSTATUS;
+    stdcall;
+
+  BCryptExportKey: function(hKey: BCRYPT_KEY_HANDLE; hExportKey: BCRYPT_KEY_HANDLE;
+    pszBlobType: LPCWSTR; var pbOutput; cbOutput: ULONG; out pcbResult: ULONG;
+    dwFlags: ULONG): NTSTATUS;
+    stdcall;
+
+  BCryptFinalizeKeyPair: function(hKey: BCRYPT_KEY_HANDLE; dwFlags: ULONG): NTSTATUS;
+    stdcall;
+
+  BCryptGenerateKeyPair: function(hAlgorithm: BCRYPT_ALG_HANDLE;
+    out phKey: BCRYPT_KEY_HANDLE; dwLength: ULONG; dwFlags: ULONG): NTSTATUS;
+    stdcall;
+
+  BCryptImportKeyPair: function(hAlgorithm: BCRYPT_ALG_HANDLE;
+    hImportKey: BCRYPT_KEY_HANDLE; pszBlobType: LPCWSTR;
+    out phKey: BCRYPT_KEY_HANDLE; const pbInput; cbInput: ULONG;
+    dwFlags: ULONG): NTSTATUS;
+    stdcall;
+
+  BCryptOpenAlgorithmProvider: function(out phAlgorithm: BCRYPT_ALG_HANDLE;
+    pszAlgId: LPCWSTR; pszImplementation: LPCWSTR; dwFlags: ULONG): NTSTATUS;
+    stdcall;
+
+  BCryptSignHash: function(hKey: BCRYPT_KEY_HANDLE; pPaddingInfo: Pointer;
+    const pbInput; cbInput: ULONG; var pbOutput; cbOutput: ULONG;
+    out pcbResult: ULONG; dwFlags: ULONG): NTSTATUS;
+    stdcall;
+
+  BCryptVerifySignature: function(hKey: BCRYPT_KEY_HANDLE; pPaddingInfo: Pointer;
+    const pbHash; cbHash: ULONG; const pbSignature; cbSignature: ULONG;
+    dwFlags: ULONG): NTSTATUS;
+    stdcall;
+
+type
+  { ECDSA-P256 key blob formats specific to BCrypt }
+  TBCryptPrivateKeyBlob = record
+    Header: BCRYPT_ECCKEY_BLOB;
+    Public_x: TECDSAInt256;
+    Public_y: TECDSAInt256;
+    Private_d: TECDSAInt256;
+    procedure Clear;
+  end;
+  TBCryptPublicKeyBlob = record
+    Header: BCRYPT_ECCKEY_BLOB;
+    Public_x: TECDSAInt256;
+    Public_y: TECDSAInt256;
+    procedure Clear;
+  end;
+
+procedure InitBCryptFunctions;
+var
+  M: HMODULE;
+
+  procedure InitFunc(out AProc: Pointer; const AProcName: PAnsiChar);
+  begin
+    AProc := GetProcAddress(M, AProcName);
+    if not Assigned(AProc) then
+      raise EECDSAError.CreateFmt('Failed to get address of %s in bcrypt.dll',
+        [AProcName]);
+  end;
+
+var
+  SystemDir: array[0..MAX_PATH-1] of Char;
+begin
+  if BCryptFunctionsInitialized then begin
+    MemoryBarrier;
+    Exit;
+  end;
+
+  GetSystemDirectory(SystemDir, SizeOf(SystemDir) div SizeOf(SystemDir[0]));
+  M := LoadLibrary(PChar(SystemDir + '\bcrypt.dll'));
+  if M = 0 then
+    raise EECDSAError.Create('Failed to load bcrypt.dll');
+
+  InitFunc(@BCryptCloseAlgorithmProvider, 'BCryptCloseAlgorithmProvider');
+  InitFunc(@BCryptDestroyKey, 'BCryptDestroyKey');
+  InitFunc(@BCryptExportKey, 'BCryptExportKey');
+  InitFunc(@BCryptFinalizeKeyPair, 'BCryptFinalizeKeyPair');
+  InitFunc(@BCryptGenerateKeyPair, 'BCryptGenerateKeyPair');
+  InitFunc(@BCryptImportKeyPair, 'BCryptImportKeyPair');
+  InitFunc(@BCryptOpenAlgorithmProvider, 'BCryptOpenAlgorithmProvider');
+  InitFunc(@BCryptSignHash, 'BCryptSignHash');
+  InitFunc(@BCryptVerifySignature, 'BCryptVerifySignature');
+
+  MemoryBarrier;
+  BCryptFunctionsInitialized := True;
+end;
+
+{ TBCryptPrivateKeyBlob }
+
+procedure TBCryptPrivateKeyBlob.Clear;
+begin
+  FillChar(Self, SizeOf(Self), 0);
+end;
+
+{ TBCryptPublicKeyBlob }
+
+procedure TBCryptPublicKeyBlob.Clear;
+begin
+  FillChar(Self, SizeOf(Self), 0);
+end;
+
+{ TECDSAPublicKey }
+
+procedure TECDSAPublicKey.Clear;
+begin
+  FillChar(Self, SizeOf(Self), 0);
+end;
+
+{ TECDSAPrivateKey }
+
+procedure TECDSAPrivateKey.Clear;
+begin
+  FillChar(Self, SizeOf(Self), 0);
+end;
+
+{ TECDSASignature }
+
+procedure TECDSASignature.Clear;
+begin
+  FillChar(Self, SizeOf(Self), 0);
+end;
+
+{ TECDSAKey }
+
+constructor TECDSAKey.Create;
+begin
+  inherited;
+  InitBCryptFunctions;
+  var LAlgorithmHandle: BCRYPT_ALG_HANDLE;
+  CheckStatus('BCryptOpenAlgorithmProvider',
+    BCryptOpenAlgorithmProvider(LAlgorithmHandle, BCRYPT_ECDSA_P256_ALGORITHM,
+      nil, 0));
+  FAlgorithmHandle := LAlgorithmHandle;  { assign only on success }
+end;
+
+destructor TECDSAKey.Destroy;
+begin
+  DestroyKey;
+  if FAlgorithmHandle <> 0 then
+    BCryptCloseAlgorithmProvider(FAlgorithmHandle, 0);
+  inherited;
+end;
+
+class procedure TECDSAKey.CheckStatus(const AFunctionName: String;
+  const AStatus: NTSTATUS);
+begin
+  if AStatus <> 0 then
+    raise EECDSAError.CreateFmt('%s failed with error code 0x%x',
+      [AFunctionName, AStatus]);
+end;
+
+procedure TECDSAKey.DestroyKey;
+begin
+  const H = FKeyHandle;
+  if H <> 0 then begin
+    FKeyHandle := 0;
+    BCryptDestroyKey(H);
+  end;
+end;
+
+procedure TECDSAKey.ExportPrivateKey(out APrivateKey: TECDSAPrivateKey);
+begin
+  KeyHandleRequired;
+
+  var KeyBlob: TBCryptPrivateKeyBlob;
+  { Initially clear KeyBlob just to make it easier to verify that
+    BCryptExportKey overwrites the entire record }
+  KeyBlob.Clear;
+  try
+    var ResultSize: ULONG;
+    CheckStatus('BCryptExportKey',
+      BCryptExportKey(FKeyHandle, 0, BCRYPT_ECCPRIVATE_BLOB, KeyBlob,
+        SizeOf(KeyBlob), ResultSize, 0));
+
+    if ResultSize <> SizeOf(KeyBlob) then
+      raise EECDSAError.Create('BCryptExportKey result invalid (1)');
+    if KeyBlob.Header.dwMagic <> BCRYPT_ECDSA_PRIVATE_P256_MAGIC then
+      raise EECDSAError.Create('BCryptExportKey result invalid (2)');
+    if KeyBlob.Header.cbKey <> SizeOf(KeyBlob.Public_x) then
+      raise EECDSAError.Create('BCryptExportKey result invalid (3)');
+
+    APrivateKey.PublicKey.Public_x := KeyBlob.Public_x;
+    APrivateKey.PublicKey.Public_y := KeyBlob.Public_y;
+    APrivateKey.Private_d := KeyBlob.Private_d;
+  finally
+    { Security: don't leave copy of private key on the stack }
+    KeyBlob.Clear;
+  end;
+end;
+
+procedure TECDSAKey.ExportPublicKey(out APublicKey: TECDSAPublicKey);
+begin
+  KeyHandleRequired;
+
+  var KeyBlob: TBCryptPublicKeyBlob;
+  { Initially clear KeyBlob just to make it easier to verify that
+    BCryptExportKey overwrites the entire record }
+  KeyBlob.Clear;
+  try
+    var ResultSize: ULONG;
+    CheckStatus('BCryptExportKey',
+      BCryptExportKey(FKeyHandle, 0, BCRYPT_ECCPUBLIC_BLOB, KeyBlob,
+        SizeOf(KeyBlob), ResultSize, 0));
+
+    if ResultSize <> SizeOf(KeyBlob) then
+      raise EECDSAError.Create('BCryptExportKey result invalid (1)');
+    if KeyBlob.Header.dwMagic <> BCRYPT_ECDSA_PUBLIC_P256_MAGIC then
+      raise EECDSAError.Create('BCryptExportKey result invalid (2)');
+    if KeyBlob.Header.cbKey <> SizeOf(KeyBlob.Public_x) then
+      raise EECDSAError.Create('BCryptExportKey result invalid (3)');
+
+    APublicKey.Public_x := KeyBlob.Public_x;
+    APublicKey.Public_y := KeyBlob.Public_y;
+  finally
+    { There's no private key, but clear anyway for consistency }
+    KeyBlob.Clear;
+  end;
+end;
+
+procedure TECDSAKey.GenerateKeyPair;
+begin
+  DestroyKey;
+
+  var LKeyHandle: BCRYPT_KEY_HANDLE;
+  CheckStatus('BCryptGenerateKeyPair',
+    BCryptGenerateKeyPair(FAlgorithmHandle, LKeyHandle, 256, 0));
+  try
+    CheckStatus('BCryptFinalizeKeyPair',
+      BCryptFinalizeKeyPair(LKeyHandle, 0));
+  except
+    BCryptDestroyKey(LKeyHandle);
+    raise;
+  end;
+  FKeyHandle := LKeyHandle;  { assign only on success }
+end;
+
+procedure TECDSAKey.ImportPrivateKey([ref] const APrivateKey: TECDSAPrivateKey);
+begin
+  DestroyKey;
+
+  var KeyBlob: TBCryptPrivateKeyBlob;
+  try
+    KeyBlob.Header.dwMagic := BCRYPT_ECDSA_PRIVATE_P256_MAGIC;
+    KeyBlob.Header.cbKey := SizeOf(KeyBlob.Public_x);
+    KeyBlob.Public_x := APrivateKey.PublicKey.Public_x;
+    KeyBlob.Public_y := APrivateKey.PublicKey.Public_y;
+    KeyBlob.Private_d := APrivateKey.Private_d;
+
+    var LKeyHandle: BCRYPT_KEY_HANDLE;
+    CheckStatus('BCryptImportKeyPair',
+      BCryptImportKeyPair(FAlgorithmHandle, 0, BCRYPT_ECCPRIVATE_BLOB,
+        LKeyHandle, KeyBlob, SizeOf(KeyBlob), 0));
+    FKeyHandle := LKeyHandle;  { assign only on success }
+  finally
+    { Security: don't leave copy of private key on the stack }
+    KeyBlob.Clear;
+  end;
+end;
+
+procedure TECDSAKey.ImportPublicKey([ref] const APublicKey: TECDSAPublicKey);
+begin
+  DestroyKey;
+
+  var KeyBlob: TBCryptPublicKeyBlob;
+  try
+    KeyBlob.Header.dwMagic := BCRYPT_ECDSA_PUBLIC_P256_MAGIC;
+    KeyBlob.Header.cbKey := SizeOf(KeyBlob.Public_x);
+    KeyBlob.Public_x := APublicKey.Public_x;
+    KeyBlob.Public_y := APublicKey.Public_y;
+
+    var LKeyHandle: BCRYPT_KEY_HANDLE;
+    CheckStatus('BCryptImportKeyPair',
+      BCryptImportKeyPair(FAlgorithmHandle, 0, BCRYPT_ECCPUBLIC_BLOB,
+        LKeyHandle, KeyBlob, SizeOf(KeyBlob), 0));
+    FKeyHandle := LKeyHandle;  { assign only on success }
+  finally
+    { There's no private key, but clear anyway for consistency }
+    KeyBlob.Clear;
+  end;
+end;
+
+procedure TECDSAKey.KeyHandleRequired;
+begin
+  if FKeyHandle = 0 then
+    raise EECDSAError.Create('No key has been assigned');
+end;
+
+procedure TECDSAKey.SignHash(const AHash: array of Byte;
+  out ASignature: TECDSASignature);
+begin
+  KeyHandleRequired;
+
+  { Initially clear ASignature just to make it easier to verify that
+    BCryptSignHash overwrites the entire record }
+  ASignature.Clear;
+
+  var ResultSize: ULONG;
+  CheckStatus('BCryptSignHash',
+    BCryptSignHash(FKeyHandle, nil, AHash[0], ULONG(Length(AHash)), ASignature,
+      SizeOf(ASignature), ResultSize, 0));
+
+  if ResultSize <> SizeOf(ASignature) then
+    raise EECDSAError.Create('BCryptSignHash result size invalid');
+end;
+
+function TECDSAKey.VerifySignature(const AHash: array of Byte;
+  const ASignature: TECDSASignature): Boolean;
+begin
+  KeyHandleRequired;
+
+  const Status = BCryptVerifySignature(FKeyHandle, nil, AHash[0],
+    ULONG(Length(AHash)), ASignature, SizeOf(ASignature), 0);
+  if Status = STATUS_INVALID_SIGNATURE then
+    Result := False
+  else begin
+    CheckStatus('BCryptVerifySignature', Status);
+    Result := True;
+  end;
+end;
+
+end.

+ 331 - 0
Components/ISSigFunc.pas

@@ -0,0 +1,331 @@
+unit ISSigFunc;
+
+{
+  Inno Setup
+  Copyright (C) 1997-2025 Jordan Russell
+  Portions by Martijn Laan
+  For conditions of distribution and use, see LICENSE.TXT.
+
+  Functions for creating/verifying .issig signatures and importing/exporting
+  text-based keys
+}
+
+interface
+
+uses
+  Windows, SysUtils, Classes, ECDSA, SHA256;
+
+type
+  TISSigVerifySignatureResult = (vsrSuccess, vsrMalformed, vsrKeyNotFound,
+    vsrBadSignature);
+  TISSigImportKeyResult = (ikrSuccess, ikrMalformed, ikrNotPrivateKey);
+
+{ Preferred, hardened functions for loading/saving .issig and key file text }
+function ISSigLoadTextFromFile(const AFilename: String): String;
+procedure ISSigSaveTextToFile(const AFilename, AText: String);
+
+function ISSigCreateSignatureText(const AKey: TECDSAKey;
+  const AFileSize: Int64; const AFileHash: TSHA256Digest): String;
+function ISSigVerifySignatureText(const AAllowedKeys: array of TECDSAKey;
+  const AText: String; out AFileSize: Int64;
+  out AFileHash: TSHA256Digest): TISSigVerifySignatureResult;
+
+procedure ISSigExportPrivateKeyText(const AKey: TECDSAKey;
+  var APrivateKeyText: String);
+procedure ISSigExportPublicKeyText(const AKey: TECDSAKey;
+  var APublicKeyText: String);
+function ISSigImportKeyText(const AKey: TECDSAKey; const AText: String;
+  const ANeedPrivateKey: Boolean): TISSigImportKeyResult;
+
+function ISSigCalcStreamHash(const AStream: TStream): TSHA256Digest;
+
+implementation
+
+uses
+  StringScanner;
+
+const
+  ISSigTextFileLengthLimit = 500;
+
+  NonControlASCIICharsSet = [#32..#126];
+  DigitsSet = ['0'..'9'];
+  HexDigitsSet = DigitsSet + ['a'..'f'];
+
+function ECDSAInt256ToString(const Value: TECDSAInt256): String;
+begin
+  Result := SHA256DigestToString(TSHA256Digest(Value));
+end;
+
+function ECDSAInt256FromString(const S: String): TECDSAInt256;
+begin
+  TSHA256Digest(Result) := SHA256DigestFromString(S);
+end;
+
+function CalcHashToSign(const AFileSize: Int64;
+  const AFileHash: TSHA256Digest): TSHA256Digest;
+begin
+  var Context: TSHA256Context;
+  SHA256Init(Context);
+  SHA256Update(Context, AFileSize, SizeOf(AFileSize));
+  SHA256Update(Context, AFileHash, SizeOf(AFileHash));
+  Result := SHA256Final(Context);
+end;
+
+function CalcKeyID(const APublicKey: TECDSAPublicKey): TSHA256Digest;
+begin
+  Result := SHA256Buf(APublicKey, SizeOf(APublicKey));
+end;
+
+function ConsumeLineValue(var SS: TStringScanner; const AIdent: String;
+  var AValue: String; const AMinValueLength, AMaxValueLength: Integer;
+  const AAllowedChars: TSysCharSet): Boolean;
+begin
+  Result := False;
+  if SS.Consume(AIdent) and SS.Consume(' ') then
+    if SS.ConsumeMultiToString(AAllowedChars, AValue, AMinValueLength,
+       AMaxValueLength) > 0 then begin
+      { CRLF and LF line breaks are allowed (but not CR) }
+      SS.Consume(#13);
+      Result := SS.Consume(#10);
+    end;
+end;
+
+function ISSigLoadTextFromFile(const AFilename: String): String;
+{ Reads the specified file's contents into a string. This is intended only for
+  loading .issig and key files. If the file appears to be invalid (e.g., if
+  it is too large or contains invalid characters), then an empty string is
+  returned, which will be reported as malformed when it is processed by
+  ISSigVerifySignatureText or ISSigImportKeyText. }
+begin
+  var U: UTF8String;
+  SetLength(U, ISSigTextFileLengthLimit + 1);
+
+  const F = TFileStream.Create(AFilename, fmOpenRead or fmShareDenyWrite);
+  try
+    const BytesRead = F.Read(U[Low(U)], Length(U));
+    if BytesRead >= Length(U) then
+      Exit('');
+    SetLength(U, BytesRead);
+  finally
+    F.Free;
+  end;
+
+  { Defense-in-depth: Reject any non-CRLF control characters up front, as well
+    as any non-ASCII characters (to avoid any possible issues with converting
+    invalid multibyte characters) }
+  for var C in U do
+    if not CharInSet(C, [#10, #13, #32..#126]) then
+      Exit('');
+
+  Result := String(U);
+end;
+
+procedure ISSigSaveTextToFile(const AFilename, AText: String);
+begin
+  const F = TFileStream.Create(AFilename, fmCreate or fmShareExclusive);
+  try
+    const U = UTF8String(AText);
+    if U <> '' then
+      F.WriteBuffer(U[Low(U)], Length(U));
+  finally
+    F.Free;
+  end;
+end;
+
+function ISSigCreateSignatureText(const AKey: TECDSAKey;
+  const AFileSize: Int64; const AFileHash: TSHA256Digest): String;
+begin
+  { File size is limited to 16 digits (enough for >9 EB) }
+  if (AFileSize < 0) or (AFileSize > 9_999_999_999_999_999) then
+    raise Exception.Create('File size out of range');
+
+  var PublicKey: TECDSAPublicKey;
+  AKey.ExportPublicKey(PublicKey);
+
+  const HashToSign = CalcHashToSign(AFileSize, AFileHash);
+  var Sig: TECDSASignature;
+  AKey.SignHash(HashToSign, Sig);
+
+  Result := Format(
+    'format issig-v1'#13#10 +
+    'file-size %d'#13#10 +
+    'file-hash %s'#13#10 +
+    'key-id %s'#13#10 +
+    'sig-r %s'#13#10 +
+    'sig-s %s'#13#10,
+    [AFileSize,
+     SHA256DigestToString(AFileHash),
+     SHA256DigestToString(CalcKeyID(PublicKey)),
+     ECDSAInt256ToString(Sig.Sig_r),
+     ECDSAInt256ToString(Sig.Sig_s)]);
+end;
+
+function ISSigVerifySignatureText(const AAllowedKeys: array of TECDSAKey;
+  const AText: String; out AFileSize: Int64;
+  out AFileHash: TSHA256Digest): TISSigVerifySignatureResult;
+var
+  TextValues: record
+    Format, FileSize, FileHash, KeyID, Sig_r, Sig_s: String;
+  end;
+begin
+  { To be extra safe, clear the "out" parameters just in case the caller isn't
+    properly checking the function result }
+  AFileSize := -1;
+  FillChar(AFileHash, SizeOf(AFileHash), 0);
+
+  if Length(AText) > ISSigTextFileLengthLimit then
+    Exit(vsrMalformed);
+
+  var SS := TStringScanner.Create(AText);
+  if not ConsumeLineValue(SS, 'format', TextValues.Format, 8, 8, NonControlASCIICharsSet) or
+     (TextValues.Format <> 'issig-v1') or
+     not ConsumeLineValue(SS, 'file-size', TextValues.FileSize, 1, 16, DigitsSet) or
+     not ConsumeLineValue(SS, 'file-hash', TextValues.FileHash, 64, 64, HexDigitsSet) or
+     not ConsumeLineValue(SS, 'key-id', TextValues.KeyID, 64, 64, HexDigitsSet) or
+     not ConsumeLineValue(SS, 'sig-r', TextValues.Sig_r, 64, 64, HexDigitsSet) or
+     not ConsumeLineValue(SS, 'sig-s', TextValues.Sig_s, 64, 64, HexDigitsSet) or
+     not SS.ReachedEnd then
+    Exit(vsrMalformed);
+
+  { Don't allow leading zeros on file-size }
+  if (Length(TextValues.FileSize) > 1) and
+     (TextValues.FileSize[Low(TextValues.FileSize)] = '0') then
+    Exit(vsrMalformed);
+
+  { Find the key that matches the key ID }
+  var KeyUsed: TECDSAKey := nil;
+  const KeyID = SHA256DigestFromString(TextValues.KeyID);
+  for var K in AAllowedKeys do begin
+    var PublicKey: TECDSAPublicKey;
+    K.ExportPublicKey(PublicKey);
+    if SHA256DigestsEqual(KeyID, CalcKeyID(PublicKey)) then begin
+      KeyUsed := K;
+      Break;
+    end;
+  end;
+  if KeyUsed = nil then
+    Exit(vsrKeyNotFound);
+
+  const UnverifiedFileSize = StrToInt64(TextValues.FileSize);
+  const UnverifiedFileHash = SHA256DigestFromString(TextValues.FileHash);
+  const HashToSign = CalcHashToSign(UnverifiedFileSize, UnverifiedFileHash);
+  var Sig: TECDSASignature;
+  Sig.Sig_r := ECDSAInt256FromString(TextValues.Sig_r);
+  Sig.Sig_s := ECDSAInt256FromString(TextValues.Sig_s);
+  if KeyUsed.VerifySignature(HashToSign, Sig) then begin
+    AFileSize := UnverifiedFileSize;
+    AFileHash := UnverifiedFileHash;
+    Result := vsrSuccess;
+  end else
+    Result := vsrBadSignature;
+end;
+
+procedure ISSigExportPrivateKeyText(const AKey: TECDSAKey;
+  var APrivateKeyText: String);
+begin
+  var PrivateKey: TECDSAPrivateKey;
+  try
+    AKey.ExportPrivateKey(PrivateKey);
+
+    APrivateKeyText := Format(
+      'format issig-private-key'#13#10 +
+      'key-id %s'#13#10 +
+      'public-x %s'#13#10 +
+      'public-y %s'#13#10 +
+      'private-d %s'#13#10,
+      [SHA256DigestToString(CalcKeyID(PrivateKey.PublicKey)),
+       ECDSAInt256ToString(PrivateKey.PublicKey.Public_x),
+       ECDSAInt256ToString(PrivateKey.PublicKey.Public_y),
+       ECDSAInt256ToString(PrivateKey.Private_d)]);
+  finally
+    PrivateKey.Clear;
+  end;
+end;
+
+procedure ISSigExportPublicKeyText(const AKey: TECDSAKey;
+  var APublicKeyText: String);
+begin
+  var PublicKey: TECDSAPublicKey;
+  try
+    AKey.ExportPublicKey(PublicKey);
+
+    APublicKeyText := Format(
+      'format issig-public-key'#13#10 +
+      'key-id %s'#13#10 +
+      'public-x %s'#13#10 +
+      'public-y %s'#13#10,
+      [SHA256DigestToString(CalcKeyID(PublicKey)),
+       ECDSAInt256ToString(PublicKey.Public_x),
+       ECDSAInt256ToString(PublicKey.Public_y)]);
+  finally
+    PublicKey.Clear;
+  end;
+end;
+
+function ISSigImportKeyText(const AKey: TECDSAKey; const AText: String;
+  const ANeedPrivateKey: Boolean): TISSigImportKeyResult;
+var
+  TextValues: record
+    Format, KeyID, Public_x, Public_y, Private_d: String;
+  end;
+begin
+  Result := ikrMalformed;
+  if Length(AText) > ISSigTextFileLengthLimit then
+    Exit;
+
+  var SS := TStringScanner.Create(AText);
+  if not ConsumeLineValue(SS, 'format', TextValues.Format, 16, 17, NonControlASCIICharsSet) then
+    Exit;
+  var HasPrivateKey := False;
+  if TextValues.Format = 'issig-private-key' then
+    HasPrivateKey := True
+  else if TextValues.Format = 'issig-public-key' then
+    { already False }
+  else
+    Exit;
+
+  if not ConsumeLineValue(SS, 'key-id', TextValues.KeyID, 64, 64, HexDigitsSet) or
+     not ConsumeLineValue(SS, 'public-x', TextValues.Public_x, 64, 64, HexDigitsSet) or
+     not ConsumeLineValue(SS, 'public-y', TextValues.Public_y, 64, 64, HexDigitsSet) then
+    Exit;
+  if HasPrivateKey then
+    if not ConsumeLineValue(SS, 'private-d', TextValues.Private_d, 64, 64, HexDigitsSet) then
+      Exit;
+  if not SS.ReachedEnd then
+    Exit;
+
+  var PrivateKey: TECDSAPrivateKey;
+  PrivateKey.PublicKey.Public_x := ECDSAInt256FromString(TextValues.Public_x);
+  PrivateKey.PublicKey.Public_y := ECDSAInt256FromString(TextValues.Public_y);
+
+  { Verify that the key ID is correct for the public key values }
+  if not SHA256DigestsEqual(SHA256DigestFromString(TextValues.KeyID),
+     CalcKeyID(PrivateKey.PublicKey)) then
+    Exit;
+
+  if ANeedPrivateKey then begin
+    if not HasPrivateKey then
+      Exit(ikrNotPrivateKey);
+    PrivateKey.Private_d := ECDSAInt256FromString(TextValues.Private_d);
+    AKey.ImportPrivateKey(PrivateKey);
+  end else
+    AKey.ImportPublicKey(PrivateKey.PublicKey);
+  Result := ikrSuccess;
+end;
+
+function ISSigCalcStreamHash(const AStream: TStream): TSHA256Digest;
+var
+  Buf: array[0..$FFFF] of Byte;
+begin
+  var Context: TSHA256Context;
+  SHA256Init(Context);
+  while True do begin
+    const BytesRead = Cardinal(AStream.Read(Buf, SizeOf(Buf)));
+    if BytesRead = 0 then
+      Break;
+    SHA256Update(Context, Buf, BytesRead);
+  end;
+  Result := SHA256Final(Context);
+end;
+
+end.

+ 24 - 1
Components/SHA256.pas

@@ -9,7 +9,7 @@ unit SHA256;
 interface
 interface
 
 
 uses
 uses
-  System.Hash;
+  SysUtils, System.Hash;
 
 
 type
 type
   TSHA256Context = record
   TSHA256Context = record
@@ -24,6 +24,7 @@ function SHA256Final(var ctx: TSHA256Context): TSHA256Digest;
 function SHA256Buf(const Buffer; Len: Cardinal): TSHA256Digest;
 function SHA256Buf(const Buffer; Len: Cardinal): TSHA256Digest;
 function SHA256DigestsEqual(const A, B: TSHA256Digest): Boolean;
 function SHA256DigestsEqual(const A, B: TSHA256Digest): Boolean;
 function SHA256DigestToString(const D: TSHA256Digest): String;
 function SHA256DigestToString(const D: TSHA256Digest): String;
+function SHA256DigestFromString(const S: String): TSHA256Digest;
 
 
 implementation
 implementation
 
 
@@ -82,4 +83,26 @@ begin
   SetString(Result, Buf, 64);
   SetString(Result, Buf, 64);
 end;
 end;
 
 
+function SHA256DigestFromString(const S: String): TSHA256Digest;
+begin
+  if Length(S) <> 64 then
+    raise EConvertError.Create('Invalid string length');
+
+  for var I := 0 to 63 do begin
+    var B: Byte;
+    const C = UpCase(S.Chars[I]);
+    case C of
+      '0'..'9': B := Byte(Ord(C) - Ord('0'));
+      'A'..'F': B := Byte(Ord(C) - (Ord('A') - 10));
+    else
+      raise EConvertError.Create('Invalid digit character');
+    end;
+    const ResultIndex = I shr 1;
+    if not Odd(I) then
+      Result[ResultIndex] := Byte(B shl 4)
+    else
+      Result[ResultIndex] := Result[ResultIndex] or B;
+  end;
+end;
+
 end.
 end.

+ 123 - 0
Components/StringScanner.pas

@@ -0,0 +1,123 @@
+unit StringScanner;
+
+{
+  Inno Setup
+  Copyright (C) 1997-2025 Jordan Russell
+  Portions by Martijn Laan
+  For conditions of distribution and use, see LICENSE.TXT.
+
+  TStringScanner
+}
+
+interface
+
+uses
+  SysUtils;
+
+type
+  TStringScanner = record
+  strict private
+    FStr: String;
+    FPosition: Integer;
+    function GetReachedEnd: Boolean;
+    function GetRemainingCount: Integer;
+  public
+    class function Create(const AString: String): TStringScanner; static;
+    function Consume(const C: Char): Boolean; overload;
+    function Consume(const S: String): Boolean; overload;
+    function ConsumeMulti(const C: TSysCharSet; const AMinChars: Integer = 1;
+      const AMaxChars: Integer = Maxint): Integer;
+    function ConsumeMultiToString(const C: TSysCharSet;
+      var ACapturedString: String; const AMinChars: Integer = 1;
+      const AMaxChars: Integer = Maxint): Integer;
+    property ReachedEnd: Boolean read GetReachedEnd;
+    property RemainingCount: Integer read GetRemainingCount;
+    property Str: String read FStr;
+  end;
+
+implementation
+
+{$ZEROBASEDSTRINGS OFF}
+
+{ TStringScanner }
+
+class function TStringScanner.Create(const AString: String): TStringScanner;
+begin
+  Result.FPosition := 1;
+  Result.FStr := AString;
+end;
+
+function TStringScanner.Consume(const C: Char): Boolean;
+begin
+  Result := (GetRemainingCount > 0) and (FStr[FPosition] = C);
+  if Result then
+    Inc(FPosition);
+end;
+
+function TStringScanner.Consume(const S: String): Boolean;
+begin
+  const SLen = Length(S);
+  if SLen > GetRemainingCount then
+    Exit(False);
+
+  for var I := 0 to SLen-1 do
+    if FStr[FPosition + I] <> S[I+1] then
+      Exit(False);
+
+  Inc(FPosition, SLen);
+  Result := True;
+end;
+
+function TStringScanner.ConsumeMulti(const C: TSysCharSet;
+  const AMinChars: Integer = 1; const AMaxChars: Integer = Maxint): Integer;
+begin
+  if (AMinChars <= 0) or (AMinChars > AMaxChars) then
+    raise Exception.Create('TStringScanner.ConsumeMulti: Invalid parameter');
+
+  const Remain = GetRemainingCount;
+  if Remain < AMinChars then
+    Exit(0);
+
+  Result := 0;
+  while (Result < AMaxChars) and (Result < Remain) and
+     CharInSet(FStr[FPosition + Result], C) do
+    Inc(Result);
+
+  if Result < AMinChars then
+    Result := 0
+  else
+    Inc(FPosition, Result);
+end;
+
+function TStringScanner.ConsumeMultiToString(const C: TSysCharSet;
+  var ACapturedString: String; const AMinChars: Integer = 1;
+  const AMaxChars: Integer = Maxint): Integer;
+begin
+  const StartPos = FPosition;
+  Result := ConsumeMulti(C, AMinChars, AMaxChars);
+  if Result > 0 then
+    ACapturedString := Copy(FStr, StartPos, Result)
+  else
+    ACapturedString := '';
+end;
+
+function TStringScanner.GetReachedEnd: Boolean;
+begin
+  Result := (GetRemainingCount = 0);
+end;
+
+function TStringScanner.GetRemainingCount: Integer;
+begin
+  { The "<= 0" check exists to protect against OOB reads in case someone calls
+    into an instance that was never properly initialized (via Create).
+    Inside TStringScanner, FStr[FPosition] must not be accessed unless
+    GetRemainingCount is called first and returns a nonzero value. }
+
+  const Len = Length(FStr);
+  if (FPosition <= 0) or (FPosition > Len) then
+    Result := 0
+  else
+    Result := Len - FPosition + 1;
+end;
+
+end.

+ 249 - 0
Projects/ISSigTool.dpr

@@ -0,0 +1,249 @@
+program ISSigTool;
+
+{
+  Inno Setup
+  Copyright (C) 1997-2025 Jordan Russell
+  Portions by Martijn Laan
+  For conditions of distribution and use, see LICENSE.TXT.
+
+  "issigtool" utility
+}
+
+uses
+  SafeDLLPath in '..\Components\SafeDLLPath.pas',
+  SysUtils,
+  Classes,
+  PathFunc in '..\Components\PathFunc.pas',
+  SHA256 in '..\Components\SHA256.pas',
+  ECDSA in '..\Components\ECDSA.pas',
+  StringScanner in '..\Components\StringScanner.pas',
+  ISSigFunc in '..\Components\ISSigFunc.pas',
+  Shared.CommonFunc in 'Src\Shared.CommonFunc.pas',
+  Shared.FileClass in 'Src\Shared.FileClass.pas',
+  Shared.Int64Em in 'Src\Shared.Int64Em.pas';
+
+{$APPTYPE CONSOLE}
+{$SETPEOSVERSION 6.1}
+{$SETPESUBSYSVERSION 6.1}
+{$WEAKLINKRTTI ON}
+
+{$R *.res}
+
+var
+  KeyFilename: String;
+
+procedure RaiseFatalError(const Msg: String);
+begin
+  raise Exception.Create(Msg);
+end;
+
+procedure RaiseFatalErrorFmt(const Msg: String; const Args: array of const);
+begin
+  raise Exception.CreateFmt(Msg, Args);
+end;
+
+function CalcFileHash(const AFile: TFile): TSHA256Digest;
+var
+  Buf: array[0..$FFFF] of Byte;
+begin
+  var Context: TSHA256Context;
+  SHA256Init(Context);
+  while True do begin
+    const BytesRead = AFile.Read(Buf, SizeOf(Buf));
+    if BytesRead = 0 then
+      Break;
+    SHA256Update(Context, Buf, BytesRead);
+  end;
+  Result := SHA256Final(Context);
+end;
+
+procedure CheckImportKeyResult(const AResult: TISSigImportKeyResult);
+begin
+  case AResult of
+    ikrSuccess:
+      Exit;
+    ikrMalformed:
+      RaiseFatalError('Key file is malformed');
+    ikrNotPrivateKey:
+      RaiseFatalError('Key file must be a private key when signing');
+  end;
+  RaiseFatalError('Unknown import key result');
+end;
+
+procedure CommandGeneratePrivateKey;
+begin
+  if NewFileExists(KeyFilename) then
+    RaiseFatalError('Key file already exists');
+
+  const Key = TECDSAKey.Create;
+  try
+    Key.GenerateKeyPair;
+
+    var PrivateKeyText: String;
+    ISSigExportPrivateKeyText(Key, PrivateKeyText);
+    ISSigSaveTextToFile(KeyFilename, PrivateKeyText);
+  finally
+    Key.Free;
+  end;
+end;
+
+procedure SignSingleFile(const AKey: TECDSAKey; const AFilename: String);
+begin
+  var FileSize: Int64;
+  var FileHash: TSHA256Digest;
+  const F = TFile.Create(AFilename, fdOpenExisting, faRead, fsRead);
+  try
+    FileSize := Int64(F.Size);
+    FileHash := CalcFileHash(F);
+  finally
+    F.Free;
+  end;
+
+  const SigText = ISSigCreateSignatureText(AKey, FileSize, FileHash);
+  ISSigSaveTextToFile(AFilename + '.issig', SigText);
+end;
+
+procedure CommandSign(const AFilenames: TStringList);
+begin
+  const Key = TECDSAKey.Create;
+  try
+    CheckImportKeyResult(ISSigImportKeyText(Key,
+      ISSigLoadTextFromFile(KeyFilename), True));
+
+    for var CurFilename in AFilenames do
+      SignSingleFile(Key, CurFilename);
+  finally
+    Key.Free;
+  end;
+end;
+
+function VerifySingleFile(const AKey: TECDSAKey; const AFilename: String): Boolean;
+begin
+  Result := False;
+  Write(AFilename, ': ');
+
+  if not NewFileExists(AFilename) then begin
+    Writeln('MISSINGFILE (File does not exist)');
+    Exit;
+  end;
+
+  const SigFilename = AFilename + '.issig';
+  if not NewFileExists(SigFilename) then begin
+    Writeln('MISSINGSIGFILE (Signature file does not exist)');
+    Exit;
+  end;
+
+  const SigText = ISSigLoadTextFromFile(SigFilename);
+  var ExpectedFileSize: Int64;
+  var ExpectedFileHash: TSHA256Digest;
+  const VerifyResult = ISSigVerifySignatureText([AKey], SigText,
+    ExpectedFileSize, ExpectedFileHash);
+  if VerifyResult <> vsrSuccess then begin
+    case VerifyResult of
+      vsrMalformed, vsrBadSignature:
+        Writeln('BADSIGFILE (Signature file is not valid)');
+      vsrKeyNotFound:
+        Writeln('UNKNOWNKEY (Incorrect key ID)');
+    else
+      RaiseFatalError('Unknown verify result');
+    end;
+    Exit;
+  end;
+
+  const F = TFile.Create(AFilename, fdOpenExisting, faRead, fsRead);
+  try
+    if Int64(F.Size) <> ExpectedFileSize then begin
+      Writeln('WRONGSIZE (File size is incorrect)');
+      Exit;
+    end;
+    const ActualFileHash = CalcFileHash(F);
+    if not SHA256DigestsEqual(ActualFileHash, ExpectedFileHash) then begin
+      Writeln('WRONGHASH (File hash is incorrect)');
+      Exit;
+    end;
+  finally
+    F.Free;
+  end;
+
+  Writeln('OK');
+  Result := True;
+end;
+
+function CommandVerify(const AFilenames: TStringList): Boolean;
+begin
+  const Key = TECDSAKey.Create;
+  try
+    CheckImportKeyResult(ISSigImportKeyText(Key,
+      ISSigLoadTextFromFile(KeyFilename), False));
+
+    Result := True;
+    for var CurFilename in AFilenames do
+      if not VerifySingleFile(Key, CurFilename) then
+        Result := False;
+  finally
+    Key.Free;
+  end;
+end;
+
+procedure Go;
+begin
+  if KeyFilename = '' then
+    KeyFilename := GetEnv('ISSIGTOOL_KEY_FILE');
+
+  const ArgList = TStringList.Create;
+  try
+    for var I := 1 to NewParamCount do
+      ArgList.Add(NewParamStr(I));
+
+    var J := 0;
+    while J < ArgList.Count do begin
+      const S = ArgList[J];
+      if S.StartsWith('--key-file=') then begin
+        KeyFilename := S.Substring(Length('--key-file='));
+        ArgList.Delete(J);
+      end else begin
+        if S.StartsWith('-') then
+          RaiseFatalErrorFmt('Unknown option "%s"', [S]);
+        if S = '' then
+          RaiseFatalError('Empty arguments not allowed');
+        Inc(J);
+      end;
+    end;
+
+    if ArgList.Count = 0 then
+      RaiseFatalError('Missing command argument');
+    const Command = ArgList[0];
+    ArgList.Delete(0);
+
+    if KeyFilename = '' then
+      RaiseFatalError('"--key-file=" option must be specified, ' +
+        'or set the ISSIGTOOL_KEY_FILE environment variable');
+
+    if Command = 'generate-private-key' then begin
+      if ArgList.Count <> 0 then
+        RaiseFatalError('Too many arguments');
+      CommandGeneratePrivateKey;
+    end else if Command = 'sign' then begin
+      if ArgList.Count = 0 then
+        RaiseFatalError('Missing filename argument');
+      CommandSign(ArgList);
+    end else if Command = 'verify' then begin
+      if ArgList.Count = 0 then
+        RaiseFatalError('Missing filename argument');
+      if not CommandVerify(ArgList) then
+        Halt(1);
+    end else
+      RaiseFatalErrorFmt('Unknown command "%s"', [Command]);
+  finally
+    ArgList.Free;
+  end;
+end;
+
+begin
+  try
+    Go;
+  except
+    Writeln(ErrOutput, 'issigtool fatal error: ', GetExceptMessage);
+    Halt(2);
+  end;
+end.