こんな記事を見つけた。
C#.NETでライセンス認証機能を作る #Windows - Qiita
この記事では、以下の方法でライセンス認証ができると主張している。
- リクエストファイルを作る (ユーザー)
- Windowsのシリアル番号を共通鍵Aで暗号化する
- その結果をレジストリに記録する(※)とともに、リクエストファイルとして出力する
- リクエストファイルからライセンスファイルを作る (認証者)
- リクエストファイルの内容を共通鍵Aで複合化する
- その結果を共通鍵Bで暗号化し、ライセンスファイルとして出力する
- ライセンスファイルを読み込む (ユーザー)
- ライセンスファイルの内容を共通鍵Bで複合化する
- その結果が(※)で記録した内容と一致すれば、認証成功とする
- 次回以降はライセンスファイルを明示的に読み込まなくていいよう、1の結果をレジストリに記録する
筆者は「復号」もしくは「復号化」のほうが適切な言葉だと考えているが、ここでは元の記事に合わせて「複合化」としている。
しかし、この方法には以下のような欠陥があるだろう。
- ユーザーが使用するアプリケーションに共通鍵を埋め込んでおくと、アプリケーションを解析して共通鍵を取り出され、ライセンスファイルを勝手に作られる懸念がある
- というか、わざわざそんなことをしなくても、一致するべき内容がレジストリに記録されているので、それを参照して簡単に勝手にライセンスファイルを作れる
- そもそも、(リクエストファイルとして出力する)暗号文と(ライセンスファイルとして読み込む)平文を比較しても、基本的に一致するわけがなく、認証として成立しない
「顔と名前を一致させる」(型が違うので一致するわけがない) のような表現もあることを考えると、もしかしたら「同じ」ではない何らかの意味で「一致」を用いており、2番目や3番目の批判は当たらないかもしれない。
それでも、共通鍵を用いるのはまずそうという印象が強い。
そこで、本記事では、有名なデジタル署名方式を用いた、もう少しマシなライセンス認証機能を提案する。
本記事で提案するライセンス認証の方法は、元の記事のものよりはマシであることが期待できるが、安全性は保証しない。
提案手法
- リクエストファイルを作る (ユーザー)
- 十分な長さのランダムなデータを生成し、レジストリに記録する(※)
- 生成したデータの後ろに「端末を特定する情報」を結合したデータのハッシュ値を求め、リクエストファイルとして出力する
- リクエストファイルからライセンスファイルを作る (認証者)
- リクエストファイルの内容(ハッシュ値)をもとに、署名を作成し、ライセンスファイルとして出力する
- ライセンスファイルを読み込む (ユーザー)
- ライセンスファイルの内容を読み込む
- それが(※)で記録したデータの後ろに「端末を特定する情報」を結合したデータの署名として正しければ、認証成功とする
- 次回以降はライセンスファイルを明示的に読み込まなくていいよう、1の結果をレジストリに記録する
「端末を特定する情報」は、元の記事で用いているWindowsのシリアル番号、もしくはパソコンに接続されている何らかのデバイスのシリアル番号などである。
この情報のハッシュ値をそのまま用いてしまうと、情報の長さによっては総当たりなどでハッシュ値から情報を特定されてしまうおそれがあるため、ランダムなデータと組み合わせることにした。
署名および検証の実装例
今回は、ECDsaCng
クラスによる ECDSA 署名を用いてみた。
このクラスは、Windows のみで使用可能とされている。
元の記事でWindowsのシリアル番号を用いていることから、Windows環境を仮定しており、これで問題ないとみなす。
本記事の提案手法ではデータをレジストリに記録するとしているが、今回のデモでは簡単のためファイルに保存する。
また、「端末を特定する情報」も適当な固定値としている。
署名と検証に用いる鍵の用意
今回用いるライブラリが読み込める形式に合わせた鍵を用意する。
まず、JWK形式の鍵を用意する。(たとえば CyberChef で生成できる)
{
"kty": "EC",
"crv": "P-256",
"x": "DVnZGiMk8e9UM5pi6alwZ2sFjcbRwHDtRJuLa2W0-Z8",
"y": "hMl1dCfYBtQl_U3a2-ehMjXHYitqI5CY6YUUNWHgQnE",
"d": "BqXfbsGdekoRmzoUY_E5MyMSzFkZdrZcZQzPJh0aqnI",
"key_ops": [
"sign"
],
"kid": "PrivateKey"
}
これは秘密鍵である。公開鍵は d
が無く、key_ops
と kid
が違うだけ (x
と y
は同じ) なので省略する。
公開鍵 (検証用) は、以下のバイナリデータを順に結合したものである。
-
45 43 53 31
(鍵の種類) -
20 00 00 00
(鍵の要素の長さ) -
x
を Base64url デコードしたデータ (32バイト) -
y
を Base64url デコードしたデータ (32バイト)
秘密鍵 (署名用) は、以下のバイナリデータを順に結合したものである。
-
45 43 53 32
(鍵の種類) -
20 00 00 00
(鍵の要素の長さ) -
x
を Base64url デコードしたデータ (32バイト) -
y
を Base64url デコードしたデータ (32バイト) -
d
を Base64url デコードしたデータ (32バイト)
公開鍵の鍵の種類の末尾は 31
、秘密鍵の鍵の種類の末尾は 32
と異なる。
参考:c# - Import a Public key from somewhere else to CngKey? - Stack Overflow
共通コード
using System;
using System.IO;
public class FileUtils
{
// 指定されたファイルを読み込み、内容を返す
public static byte[] ReadFile(string filePath)
{
using (FileStream fs = new FileStream(filePath, FileMode.Open, FileAccess.Read))
{
int fileSize = (int)fs.Length;
byte[] result = new byte[fileSize];
int bytesRead = 0;
while (bytesRead < fs.Length)
{
int delta = fs.Read(result, bytesRead, fileSize - bytesRead);
if (delta == 0) throw new Exception("unexpected EOF");
bytesRead += delta;
}
return result;
}
}
// 指定されたファイルにデータを書き込む (ファイルが存在する場合は置き換える)
public static void WriteFile(string filePath, byte[] data)
{
using (FileStream fs = new FileStream(filePath, FileMode.Create, FileAccess.Write))
{
fs.Write(data, 0, data.Length);
}
}
}
ユーザー用アプリケーション
using System;
using System.Security.Cryptography;
using System.Text;
public class LicenseChecker
{
private readonly byte[] dataToBeSigned;
private readonly ECDsa ecdsa;
private readonly SHA256 sha256;
public LicenseChecker(byte[] randomData, string machineId)
{
// 署名検証器を初期化する
ecdsa = new ECDsaCng(CngKey.Import(Convert.FromBase64String(
"RUNTMSAAAAANWdkaIyTx71QzmmLpqXBnawWNxtHAcO1Em4trZbT5n4TJdXQn2AbUJf1N2tvnoTI1" +
"x2IraiOQmOmFFDVh4EJx"
), CngKeyBlobFormat.EccPublicBlob));
sha256 = SHA256.Create();
// 署名されるデータを準備する
byte[] machineIdEncoded = Encoding.UTF8.GetBytes(machineId);
dataToBeSigned = new byte[randomData.Length + machineIdEncoded.Length];
randomData.CopyTo(dataToBeSigned, 0);
machineIdEncoded.CopyTo(dataToBeSigned, randomData.Length);
}
// リクエストファイルの内容を生成して返す
public byte[] CreateRequestFile()
{
return sha256.ComputeHash(dataToBeSigned);
}
// ライセンスファイルの内容を受け取り、有効かどうかを返す
public bool CheckLicenseFile(byte[] licenseFile)
{
return ecdsa.VerifyData(dataToBeSigned, licenseFile, HashAlgorithmName.SHA256);
}
}
using System;
using System.IO;
using System.Security.Cryptography;
public class UserApplication
{
public static void Main(string[] args)
{
// ランダムデータを用意する
byte[] randomData;
if (File.Exists("random.bin"))
{
Console.WriteLine("ファイルからランダムデータを読み込みます。");
randomData = FileUtils.ReadFile("random.bin");
}
else
{
Console.WriteLine("ランダムデータを生成します。");
randomData = new byte[32];
RandomNumberGenerator rng = RandomNumberGenerator.Create();
rng.GetBytes(randomData);
FileUtils.WriteFile("random.bin", randomData);
}
// 端末を識別する情報を取得する
string machineId = "demo-id-1234-5678";
LicenseChecker licenseChecker = new LicenseChecker(randomData, machineId);
if (File.Exists("license.bin"))
{
// ライセンスを検証する
byte[] licenseFile = FileUtils.ReadFile("license.bin");
if (licenseChecker.CheckLicenseFile(licenseFile))
{
Console.WriteLine("ライセンス認証に成功しました。");
}
else
{
Console.WriteLine("ライセンス認証エラー!");
}
}
else
{
// リクエストファイルを生成する
byte[] requestFile = licenseChecker.CreateRequestFile();
FileUtils.WriteFile("request.bin", requestFile);
Console.WriteLine("リクエストファイルを生成しました。");
}
}
}
認証者
using System;
using System.Security.Cryptography;
public class LicenseGenerator
{
private readonly ECDsa ecdsa;
public LicenseGenerator(byte[] secretKey)
{
ecdsa = new ECDsaCng(CngKey.Import(secretKey, CngKeyBlobFormat.EccPrivateBlob));
}
// リクエストファイルの内容を受け取り、ライセンスファイルの内容を返す
public byte[] CreateLicenseFile(byte[] requestFile)
{
return ecdsa.SignHash(requestFile);
}
}
using System;
using System.IO;
public class Licenser
{
public static void Main(string[] args)
{
// デモ用にソースに埋め込んでいるが、実際はファイルなどから読み込むべきである
byte[] privateKey = Convert.FromBase64String(
"RUNTMiAAAAANWdkaIyTx71QzmmLpqXBnawWNxtHAcO1Em4trZbT5n4TJdXQn2AbUJf1N2tvnoTI1" +
"x2IraiOQmOmFFDVh4EJxBqXfbsGdekoRmzoUY/E5MyMSzFkZdrZcZQzPJh0aqnI="
);
LicenseGenerator licenseGenerator = new LicenseGenerator(privateKey);
byte[] requestFile = FileUtils.ReadFile("request.bin");
byte[] licenseFile = licenseGenerator.CreateLicenseFile(requestFile);
FileUtils.WriteFile("license.bin", licenseFile);
Console.WriteLine("ライセンスファイルを生成しました。");
}
}
実行例
- ユーザー用アプリケーションがリクエストファイルを生成する
- 認証者がリクエストファイルを読み込み、ライセンスファイルを生成する
- ユーザー用アプリケーションがライセンスファイルを読み込み、検証を行う
という流れで実行を行った。
YUKI.N>csc /nologo /out:UserApplication.exe UserApplication.cs LicenseChecker.cs FileUtils.cs
YUKI.N>csc /nologo /out:Licenser.exe Licenser.cs LicenseGenerator.cs FileUtils.cs
YUKI.N>UserApplication
ランダムデータを生成します。
リクエストファイルを生成しました。
YUKI.N>od -Ax -t x1 random.bin
000000 f0 35 bf 0d 62 c9 bb e7 15 86 be 0a 21 cc 8b 83
000010 75 64 5e b7 10 2a 8d c9 e7 73 12 62 a5 51 c7 ac
000020
YUKI.N>od -Ax -t x1 request.bin
000000 d7 c6 45 18 3c 42 ef 73 24 af fb be 0f 3c 3c 09
000010 88 01 59 6b 1a 92 ed ec 2a f4 a4 ed f6 1a 10 c3
000020
YUKI.N>Licenser
ライセンスファイルを生成しました。
YUKI.N>od -Ax -t x1 license.bin
000000 fe 12 66 67 bb 13 c9 34 86 34 45 3c 29 fc 11 b4
000010 75 7f 5e 13 cd 80 34 c2 54 89 11 00 ec b8 b6 aa
000020 56 4e 87 4a 98 8b 16 de 4c 11 30 62 78 16 a0 0a
000030 03 88 38 bb 8c 44 88 6c 98 3f b3 88 37 64 5f 1b
000040
YUKI.N>UserApplication
ファイルからランダムデータを読み込みます。
ライセンス認証に成功しました。
YUKI.N>
おわりに
有名なデジタル署名方式を用いることで、
- リクエストファイルの生成時に端末に記録される情報だけから勝手にライセンスファイルを生成するのが難しい
- アプリケーションを解析して鍵を取り出しても、それを用いてライセンスファイルを生成するのが難しい
ことが期待できるライセンス認証方式を提案した。
しかし、この方式でも、
- 検証用の鍵を、攻撃者が持っている秘密鍵に対応する公開鍵に差し替える
- プログラムを書き換え、ライセンスの確認を行う処理を迂回や無効化する
などの方法により、ライセンス認証を不正に突破されてしまう懸念がある。