65.9K
CodeProject 正在变化。 阅读更多。
Home

构建 Snap-In 应用程序:附录 A,用于安全的代码签名

starIconstarIconstarIcon
emptyStarIcon
starIcon
emptyStarIcon

3.55/5 (8投票s)

2003年9月15日

CPOL

11分钟阅读

viewsIcon

64127

downloadIcon

1094

本文建立在我之前的 Snap-In 文章基础之上,通过 .NET 代码签名增加了安全性。

引言

为了响应(感谢 Furty)我将原始示例扩展到安全版本的请求,我决定将其作为原始文章的附录来编写——这不是必需的,但它可能会增加价值。如果你还没有这样做,我鼓励你阅读原始文章(假设 CodeProject 没有移动它)。我将假设你对该文章中介绍的概念很熟悉,我不会浪费时间重复那里已经涵盖的内容。

总而言之,本文解决的问题围绕着具有开放式设计和已发布(且易于阅读)接口的安全风险。如果你正在构建一个你**希望**普通大众能够为其编写模块的应用程序,**请勿继续阅读**。本文介绍的技术确保你的 Shell **只**加载由你公司编写的模块。在我的实现中,如果 Shell 尝试加载一个未经你公司编译和签名的模块(无论是出于恶意意图还是良性实验),Shell 将会抛出错误并关闭,假设最坏的情况:有人试图破坏你的应用程序。

入门:公钥密码学和 .NET 代码签名

大多数人考虑的公钥密码学有两个方面:加密消息和加密签名。关于这两者,无论是书籍还是网络上都有大量信息,所以我将快速概述它们,以确保你掌握了足够的基础知识来理解我正在做的事情。如果你是这项技术方面的专家,请随意跳过——后面没有太多新内容。

如你所知,公钥密码学(又称非对称密码学)涉及一对公钥和私钥,它们使用一些通用算法(例如 RSA)同时创建。对于消息加密,发送方使用接收方的公钥对消息进行编码,接收方使用她的私钥进行解码。相反,发送方可以使用他的私钥对消息进行签名,接收方可以使用发送方的公钥来验证消息是否由他签名。正是后一种签名功能引起了我们的兴趣,并且可以应用于代码签名。

.NET 编译器的规范规定,在给定程序集的清单中会有一个插槽用于插入密钥签名。通过对代码进行签名,你利用此插槽确保使用你的组件的人知道这些组件确实来自你,因为将特定字节顺序插入清单中此特定插槽的唯一方法是使用你的私钥编译组件。如果只有我签名,清单的这一部分看起来像这样。

.publickey = (
00 24 00 00 04 80 00 00 94 00 00 00 06 02 00 00   // .$..............
00 24 00 00 52 53 41 31 00 04 00 00 01 00 01 00   // .$..RSA1........
9F 84 32 EE 7E CB A3 AA 31 A2 C2 47 5D CE 96 AA   // ..2.~...1..G]...
F0 DC AB 01 6F 6A CB E1 16 EB E8 9C 2C 42 BF 07   // ....oj......,B..
33 91 63 85 57 CF FB 39 CD 42 F8 84 22 53 1E E2   // 3.c.W..9.B.."S..
69 0E 4A 7C 6B 40 7C 89 22 06 5F F3 85 7B E8 84   // i.J|k@|."._..{..
69 78 C3 59 47 39 F3 2D 9C EE F1 E2 3F 11 44 99   // ix.YG9.-....?.D.
B1 CD F6 18 C3 B4 03 F7 75 14 DD 68 90 6A 45 FB   // ........u..h.jE.
ED 5B FE A8 13 7D 61 C5 4D 23 79 3E E8 41 42 1F   // .[...}a.M#y>.AB.
89 02 9E 08 64 BD 6B 13 91 53 78 76 92 AB 73 C7 ) // ....d.k..Sxv..s.

它可能看起来不像(呵呵,也许像),但由于公钥密码学中涉及的不可思议的大量排列,世界上任何其他人生成此特定签名的可能性在统计学上是不可能的。人眼可以在字节的 ASCII 转换中辨别出来的一件事是用于加密签名的算法——“RSA1”——其余的都是不可读的。

解决方案架构

解决方案的实现并没有什么你可能猜不到的。第一步将是签名组件(如果你从一开始就跟随,即`IModule`实现者)。第二步将是在消费组件(`IShell`实现者)中检查签名。一旦你拥有密钥对,就可以了!所以我现在将深入到代码细节中。

代码演练

我假设你已经下载了代码示例,以便你可以根据需要详细或粗略地进行查看。

重要:你需要知道你将无法 100% 按原样使用代码示例,因为其中涉及一个私钥,恐怕我无法与你分享。在任何我提到密钥对的地方,请确保你使用的是你自己的密钥对。

生成密钥对

使用随附的 .NET 软件创建自己的密钥对非常简单。根据你的公司情况,你可能已经拥有一个密钥对,并且可能只有少数人可以访问它。如果你的公司对私钥访问权限高度受限,你可能需要在开发过程中采取特殊步骤,并且只在部署时使用“延迟签名”真正签署你的代码。但是,对于这个示例,我将假设这是你在公司创建的第一个密钥对,或者密钥对你来说随时可用。

如果需要创建密钥对,请启动 Visual Studio .NET 命令提示符。在你的计算机或网络上选择一个好的位置来安全地保存你的密钥对(对于此演示,我只是创建了一个目录 C:\Keys\)。切换到此目录并运行强名称工具,如下所示生成密钥对:sn -k SnapIns.snk。“snk”是强名称密钥的常用扩展名,但你可以使用任何你喜欢的扩展名——“snk”没有文件关联。根据需要保护此文件——这是你的 Shell 加载模块的真实性证书。

然后,你必须从此密钥对中提取公钥。此公钥将随你的软件一起分发,以便 Shell 可以验证每个加载的模块是否由你构建(请记住,你使用私钥签署代码,并使用公钥验证你是否签署了它)。为此,请使用强名称实用程序,如下所示:sn -p SnapIns.snk SnapIns-public.snk。这两个文件,在文本编辑器中打开时,看起来都像乱码,但你可以相信第一个包含公钥和私钥,另一个只包含可以自由分发的公钥。(实际上,如果你想玩这个实用程序,你可以将二进制数据提取为 ASCII 格式,尽管它没有太多实际应用。)

签署程序集

由于 .NET 的属性,这再简单不过了。要签署程序集(组件或模块),请打开项目的 AssemblyInfo.cs 并滚动到最底部。你会看到这样一行

[assembly: AssemblyKeyFile("")]

要让编译器为你签署程序集,只需像这样更改它(当然,提供你的密钥对的正确路径)

[assembly: AssemblyKeyFile(@"C:\Keys\SnapIns.snk")]

注意:文档说你应该使用相对路径而不是绝对路径,但我使用绝对路径没有任何困难,这是我的建议,考虑到让一群程序员在相对目录结构或午餐等简单事情上达成一致是多么困难。此外,我发现很难弄清楚`..\..\..\Keys\`与我的二进制文件实际的位置关系。此外,如果你是 VB.NET 程序员,相对路径是基于你的 .vbproj 文件所在的位置,而不是你的编译程序集所在的位置!困惑吗?使用绝对路径。

警告:代码签名是一个全有或全无的命题。也就是说,要签名一个引用另一个程序集的消费程序集,**被引用的程序集**也必须被签名。如果你只尝试将 `AssemblyKeyFile` 属性添加到 `YourModule` 的 `AssemblyInfo.cs` 中,当你构建时,你会收到以下错误,因为 `Interfaces` 程序集未签名:“程序集生成失败——引用的程序集 'Interfaces' 没有强名称。”只需将 `AssemblyKeyFile` 属性添加到被引用的程序集 `Interfaces` 中,即可开始工作。几乎唯一可以不签名就能使用的程序集是 `Shell`。

使用已签名模块

如果你不想,你不必对已签名的模块做任何特殊处理,但既然这是本文的全部意义所在,你将需要对原始的 *Shell.csproj* 进行一些更改。如果你是富有进取心的人,并遵循我的建议创建了一个比 *App.config* 更健壮的外部配置文件,请确保根据需要调整这些说明。然而,像以前一样,为了保持简单和集中,我将继续使用 *App.config* 开发一个有些笨拙的解决方案。

要在运行时加载一个签名的程序集,我将需要四个信息片段,而不是我之前使用的两个。它们是:代码基(位置)、程序集包含的类型(类)、程序集的简单名称和程序集版本。所以我给 *App.config* 添加了两个键,并将“path”键重命名为“asm”,因为我只打算提供程序集的文件名,而不是它的完整路径。由于我仍然需要完整路径,我将该信息移到了 `appSettings` 部分,并将键命名为“`ModulePath`”。这个路径更改的原因是,我现在还需要定位另一个外部文件——公钥——我将把它与模块放在一起,以便整洁地记录。

有了 App.config 中的新数据,`moduleInfo` 结构将需要扩展以容纳这些数据,当然,它需要在运行时加载。这是一项简单而无趣的工作,所以我在此不作详细说明,相信你可以在代码示例中看到我做了什么。

有趣的是 `LoadModule()` 实现中所需的更改。方法签名保持不变——没坏就没修——但我不能再使用 `Activator` 对象将我的程序集加载到运行时对象实例化中。相反,我必须使用静态 `Assembly.Load()`,向它提供磁盘上要加载的程序集的详细信息,然后将返回的 `Assembly` 实例强制转换为 `IModule` 类型。所有执行此操作的代码如下:

private IModule LoadModule(ModuleInfo moduleInfo)
  {
    Debug.Assert(moduleInfo.asm != null);
    Debug.Assert(moduleInfo.type != null);
    Debug.Assert(moduleInfo.simpleName != null);
    Debug.Assert(moduleInfo.version != null);

    try
    {
      AssemblyName asmName = new AssemblyName();
      string modulePath = ConfigurationSettings.AppSettings["ModulePath"];

      // Specify where the assembly is.
      asmName.CodeBase = string.Concat(@"file:///", 
        Path.Combine(modulePath, moduleInfo.asm));
      // Provide the culture ("" == neutral)
      asmName.CultureInfo = new CultureInfo("");
      // Give the simple name of the component.
      asmName.Name = moduleInfo.simpleName;
      // Specify the version.
      asmName.Version = new Version(moduleInfo.version);
      // Load public key information from disk.
      asmName.Flags = AssemblyNameFlags.PublicKey;
      FileStream publicKeyStream = File.Open(
        Path.Combine(modulePath, "SnapIns-public.snk"), FileMode.Open);
      byte[] publicKey = new byte[publicKeyStream.Length];
      publicKeyStream.Read(publicKey, 0, (int)publicKeyStream.Length);
      asmName.SetPublicKey(publicKey);
      publicKeyStream.Close();

      // Try to load and catch a FileLoadException.
      Assembly asm = Assembly.Load(asmName);

      // Return a cast instance.
      return (IModule)asm.CreateInstance(moduleInfo.type);
    }
    catch (System.IO.FileLoadException ex)
    {
      MessageBox.Show(
        string.Format("Attempt to load improperly configured" + 
          " or unsigned module!\n({0})",
        ex.Message), "Module Load Failed", 
          MessageBoxButtons.OK, MessageBoxIcon.Error);
      return null;
    }
  }

`AssemblyName` 的 `CodeBase` 字段需要一个有效的协议。缺点是我不能只提供一个路径,而是需要说明我正在使用文件协议;优点是这可以是 HTTP!`Name` 属性对我来说似乎有点没用,但如果未指定,则不会对程序集进行进一步检查,并且未签名的模块实际上会加载。如果你不确定在这里放什么,请在你的程序集上运行 ILDASM,并在清单中(来自 *YourModule.dll*)找到看起来与此相似的位:`.assembly YourModule {...}`。版本也很重要,所以如果你没有在 `AssemblyInfo.cs` 中手动设置版本,那么是时候开始了,除非你不介意管理自动生成的版本号。如果这些信息中的任何一个不正确,当你调用 `Assembly.Load()` 时,你将抛出 `FileLoadException`。

最后,要检查签名,你需要计划将公钥与你的 Shell 和模块一起分发。(**警告**:**不要**分发你的密钥对文件!)再次强调,我建议将所有模块放在一个目录中,并将公钥文件放在同一个目录中——这样你会更清醒。然后,使用 `FileStream` 对象加载密钥,将文件读入字节数组,并用此字节数组调用 `AssemblyName` 实例的 `SetPublicKey()`。如果模块是用匹配的私钥签名的,`Assembly.Load()` 将成功!否则它将失败,抛出 `FileLoadException`。太棒了!请记住,要使其工作,**所有**这些东西都需要组合在一起,而不仅仅是公钥;如果你的版本号错误,你将得到与模块未签名时完全相同的异常。

在我的示例中,我决定在 `LoadModule()` 方法本身中处理异常,但如果你愿意,也可以让异常冒泡到调用者(这对于实际实现可能更有意义)。我还决定检查 `LoadModule()` 的返回值是否为 `null` —— 这是对我之前 `Debug.Assert` 的一个更改 —— 并立即关闭程序,假设存在恶意代码。这非常剧烈,如何处理最终取决于你和你的公司要求。你可以悄悄地忽略失败的尝试;你可以警告用户并继续而不加载模块;或者你可以提醒用户并让她决定是否回退到使用 `Activator.CreateInstanceFrom()`,如果她不介意忽略无效或缺失的签名。

测试

要测试,你可以尝试很多事情。至少要确保测试以下内容:

  • 未签名模块
  • 公钥/私钥不匹配的已签名模块
  • 错误/缺失的代码库(CodeBase)
  • 错误/缺失的名称(Name)
  • 错误/缺失的版本(Version)

摘要

Visual Studio 在生成密钥、代码签名和检查可信签名方面做了大量自动化工作。只需将适当的程序集属性设置为有效的密钥文件,即可生成已签名(强名称)的程序集。要在运行时加载已签名程序集,需要指定额外的详细信息,包括程序集的名称和版本。然后,要实际执行签名检查,在尝试从磁盘加载之前,使用分发副本的字节(以及程序集的名称和版本!)设置公钥,只有正确签名的程序集才能成功加载而不会抛出异常。

任何问题、评论和批评都可以通过电子邮件发送至 todd.sprang@cox.net。祝您好运,编程愉快!

变更历史

  • 09.09.03, 09.11.03 - 初稿
© . All rights reserved.