Add hash-based assembly integrity check

Add hash-based assembly integrity check
This commit is contained in:
Namhyeon Go 2025-12-10 14:36:59 +09:00
parent dfb821d1d7
commit 0e15aa2b1f
5 changed files with 156 additions and 10 deletions

View File

@ -3,6 +3,9 @@
// SPDX-FileCopyrightText: Namhyeon Go <gnh1201@catswords.re.kr>, 2025 Catswords OSS and WelsonJS Contributors
// https://github.com/gnh1201/welsonjs
//
using System.Security.Cryptography;
using System.Text;
using System.Xml.Linq;
using System;
using System.Collections.Generic;
using System.Diagnostics;
@ -33,6 +36,12 @@ namespace Catswords.Phantomizer
public static string BaseUrl { get; set; } = null;
public static string LoaderNamespace { get; set; } = typeof(AssemblyLoader).Namespace;
public static string AppName { get; set; } = "Catswords";
public static string IntegrityUrl { get; set; } = null;
// Hash whitelist (values only)
private static HashSet<string> _integrityHashes = null;
private static bool _integrityLoaded = false;
private static readonly object IntegritySyncRoot = new object();
private static readonly object SyncRoot = new object();
private static bool _registered;
@ -167,16 +176,28 @@ namespace Catswords.Phantomizer
if (_registered)
return;
if (string.IsNullOrWhiteSpace(BaseUrl))
try
{
Trace.TraceError("AssemblyLoader.Register() called but BaseUrl is not set.");
throw new InvalidOperationException("AssemblyLoader.BaseUrl must be configured before Register().");
}
if (!_integrityLoaded)
LoadIntegrityManifest();
if (!BaseUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
if (string.IsNullOrWhiteSpace(BaseUrl))
throw new InvalidOperationException("BaseUrl must be configured before Register().");
if (Uri.TryCreate(BaseUrl, UriKind.Absolute, out Uri uri))
{
if (uri.Scheme != Uri.UriSchemeHttps)
throw new InvalidOperationException("BaseUrl must use HTTPS for security.");
}
else
{
throw new InvalidOperationException("BaseUrl is not a valid absolute URI.");
}
}
catch (Exception ex)
{
Trace.TraceError("AssemblyLoader.BaseUrl must use HTTPS for security.");
throw new InvalidOperationException("AssemblyLoader.BaseUrl must use HTTPS.");
Trace.TraceError("AssemblyLoader: failed to initialize: {0}", ex.Message);
throw;
}
AppDomain.CurrentDomain.AssemblyResolve += OnAssemblyResolve;
@ -236,6 +257,7 @@ namespace Catswords.Phantomizer
Trace.TraceInformation("Using cached native module: {0}", localPath);
}
EnsureIntegrityOrThrow(localPath);
EnsureSignedFileOrThrow(localPath, fileName);
IntPtr h = LoadLibrary(localPath);
@ -324,6 +346,7 @@ namespace Catswords.Phantomizer
return null;
}
EnsureIntegrityOrThrow(dllPath);
EnsureSignedFileOrThrow(dllPath, simpleName);
return Assembly.LoadFrom(dllPath);
}
@ -447,14 +470,85 @@ namespace Catswords.Phantomizer
private static bool IsFrameworkAssembly(string name)
{
return name.StartsWith("System", StringComparison.OrdinalIgnoreCase) ||
name.StartsWith("Microsoft", StringComparison.OrdinalIgnoreCase) ||
return name.StartsWith("System.", StringComparison.OrdinalIgnoreCase) ||
name.StartsWith("Microsoft.", StringComparison.OrdinalIgnoreCase) ||
name == "mscorlib" ||
name == "netstandard" ||
name == "WindowsBase" ||
name == "PresentationCore" ||
name == "PresentationFramework" ||
name.StartsWith(LoaderNamespace, StringComparison.OrdinalIgnoreCase);
name.StartsWith($"{LoaderNamespace}.", StringComparison.OrdinalIgnoreCase);
}
private static void LoadIntegrityManifest()
{
lock (IntegritySyncRoot)
{
if (_integrityLoaded)
return;
_integrityHashes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(IntegrityUrl))
{
_integrityLoaded = true;
return; // integrity disabled
}
XDocument doc;
try
{
if (Uri.TryCreate(IntegrityUrl, UriKind.Absolute, out Uri uri))
{
if (uri.Scheme != Uri.UriSchemeHttps)
throw new InvalidOperationException("IntegrityUrl must use HTTPS for security.");
using (var res = Http.GetAsync(uri).GetAwaiter().GetResult())
{
res.EnsureSuccessStatusCode();
using (var stream = res.Content.ReadAsStreamAsync().GetAwaiter().GetResult())
{
doc = XDocument.Load(stream);
}
}
}
else
{
throw new InvalidOperationException("IntegrityUrl is not a valid absolute URI.");
}
}
catch (Exception ex)
{
Trace.TraceError("AssemblyIntegrity: failed to load manifest: {0}", ex.Message);
throw new InvalidOperationException("Failed to load AssemblyIntegrity manifest.", ex);
}
XElement hashes = doc.Root?.Element("Hashes");
if (hashes == null)
{
Trace.TraceWarning("AssemblyIntegrity: <Hashes> not found. Integrity disabled.");
_integrityLoaded = true;
return;
}
foreach (var h in hashes.Elements("Hash"))
{
var algorithm = h.Attribute("algorithm")?.Value?.Trim();
if (!string.Equals(algorithm, "SHA256", StringComparison.OrdinalIgnoreCase))
continue; // only SHA256 supported
string val = h.Attribute("value")?.Value?.Trim();
if (string.IsNullOrWhiteSpace(val))
continue;
_integrityHashes.Add(val);
}
_integrityLoaded = true;
Trace.TraceInformation("AssemblyIntegrity: loaded {0} allowed hashes.", _integrityHashes.Count);
}
}
@ -484,6 +578,32 @@ namespace Catswords.Phantomizer
throw new InvalidOperationException("Invalid signature: " + logicalName);
}
private static void EnsureIntegrityOrThrow(string path)
{
if (string.IsNullOrWhiteSpace(IntegrityUrl))
return; // disabled
if (_integrityHashes == null || _integrityHashes.Count == 0)
{
Trace.TraceWarning("AssemblyIntegrity: no hashes loaded → skipping check.");
return;
}
byte[] bytes = File.ReadAllBytes(path);
// Compute hashes
string sha256 = ComputeHashHex(bytes, SHA256.Create());
// Check match
if (_integrityHashes.Contains(sha256))
{
Trace.TraceInformation("AssemblyIntegrity: hash OK for {0}", Path.GetFileName(path));
return;
}
Trace.TraceError("AssemblyIntegrity: hash mismatch! SHA256={0}", sha256);
throw new InvalidOperationException("AssemblyIntegrity check failed for: " + path);
}
private static FileSignatureStatus VerifySignature(string file)
{
@ -502,5 +622,17 @@ namespace Catswords.Phantomizer
return FileSignatureStatus.Invalid;
}
private static string ComputeHashHex(byte[] data, HashAlgorithm algorithm)
{
using (algorithm)
{
var hash = algorithm.ComputeHash(data);
var sb = new StringBuilder(hash.Length * 2);
foreach (var b in hash)
sb.Append(b.ToString("x2"));
return sb.ToString();
}
}
}
}

View File

@ -132,6 +132,7 @@ namespace WelsonJS.Launcher
loaderType.GetProperty("BaseUrl")?.SetValue(null, GetAppConfig("AssemblyBaseUrl"));
loaderType.GetProperty("LoaderNamespace")?.SetValue(null, typeof(Program).Namespace);
loaderType.GetProperty("AppName")?.SetValue(null, "WelsonJS");
//loaderType.GetProperty("IntegrityUrl")?.SetValue(null, GetAppConfig("AssemblyIntegrityUrl")); // In the future, we may use this to verify integrity.
loaderType.GetMethod("Register")?.Invoke(null, null);
var loadNativeModulesMethod = loaderType.GetMethod(

View File

@ -69,6 +69,15 @@ namespace WelsonJS.Launcher.Properties {
}
}
/// <summary>
/// https://spare-yellow-cicada.myfilebase.com/ipfs/QmYL29Z7BRvE6dnL4HPKeNvJyNJrh2LnVhzo3k9bp3wBZf과(와) 유사한 지역화된 문자열을 찾습니다.
/// </summary>
internal static string AssemblyIntegrityUrl {
get {
return ResourceManager.GetString("AssemblyIntegrityUrl", resourceCulture);
}
}
/// <summary>
/// 과(와) 유사한 지역화된 문자열을 찾습니다.
/// </summary>

View File

@ -244,4 +244,7 @@
<data name="Phantomizer" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\Catswords.Phantomizer.dll.gz;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</data>
<data name="AssemblyIntegrityUrl" xml:space="preserve">
<value>https://spare-yellow-cicada.myfilebase.com/ipfs/QmYL29Z7BRvE6dnL4HPKeNvJyNJrh2LnVhzo3k9bp3wBZf</value>
</data>
</root>

View File

@ -30,6 +30,7 @@
<add key="TelemetryBaseUrl" value="https://us.i.posthog.com"/>
<add key="TelemetryEnabled" value="true"/>
<add key="AssemblyBaseUrl" value="https://catswords.blob.core.windows.net/welsonjs/packages"/>
<add key="AssemblyIntegrityUrl" value="https://spare-yellow-cicada.myfilebase.com/ipfs/QmYL29Z7BRvE6dnL4HPKeNvJyNJrh2LnVhzo3k9bp3wBZf"/>
</appSettings>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2"/>