Firebase を利用して Google Chrome にサーバーからのメッセージデータを含めたプッシュ通知を送信するコードを紹介します。
概要
こちらの記事では、Firebase を利用して Google Chrome にプッシュ通知を送信し、クライアント側でポップアップ表示をするコードを紹介しました。ただしプッシュによりポップアップを表示し、あらかじめ設定された文字列を表示するのみで、サーバーからのメッセージデータの受信はしない動作でした。このページでは、サーバーからプッシュ通知を送る際にメッセージデータも送信し、クライアント側でポップアップの際にサーバーから受け取ったメッセージも表示できるコードを紹介します。
プッシュサーバーからデータを送信する際にはプッシュサーバー側で送信データを暗号化する必要があります。暗号化の方式は web push encryption (WebPush payload encryption)と呼ばれる処理になります。暗号化アルゴリズムに「楕円曲線ディフィー・ヘルマン鍵共有(ECDH)」を利用します。
プログラム : Webサーバー
WebサーバーにHTMLファイル、マニフェストファイル、サービスワーカーのJavaScriptファイルを設置します。
index.html
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title></title>
<meta charset="utf-8" />
<link rel="manifest" href="manifest.json">
<script type="text/javascript">
var API_KEY = 'AIza..............................xw2ns';
var GCM_ENDPOINT = 'https://android.googleapis.com/gcm/send';
function onLoad() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('./service-worker.js').then(initialiseState);
} else {
window.Demo.debug.log('Service workers aren\'t supported in this browser.');
}
}
function initialiseState() {
if (!('showNotification' in ServiceWorkerRegistration.prototype)) {
window.Demo.debug.log('Notifications aren\'t supported.');
return;
}
if (Notification.permission === 'denied') {
window.Demo.debug.log('The user has blocked notifications.');
return;
}
if (!('PushManager' in window)) {
window.Demo.debug.log('Push messaging isn\'t supported.');
return;
}
navigator.serviceWorker.ready.then(ServiceWorkerRegistInit);
}
function ServiceWorkerRegistInit(serviceWorkerRegistration) {
serviceWorkerRegistration.pushManager.getSubscription().then(SubscriptionProcInit);
}
function SubscriptionProcInit(subscription) {
//var pushButton = document.querySelector('.js-push-button');
//pushButton.disabled = false;
if (!subscription) {
return;
}
sendSubscriptionToServer(subscription);
/* Push が利用可能 */
}
/* Subscribe */
function SubscribePushNotification() {
navigator.serviceWorker.ready.then(ServiceWorkerRegist);
}
function ServiceWorkerRegist(serviceWorkerRegistration) {
/* Subscription 処理の開始前処理を記述 */
var subscribe = serviceWorkerRegistration.pushManager.subscribe({ userVisibleOnly: true });
subscribe.then(SubscriptionProc);
}
function SubscriptionProc(subscription) {
/* Subscription 完了時の処理を記述 */
return sendSubscriptionToServer(subscription);
}
function sendSubscriptionToServer(subscription) {
console.log('TODO: Implement sendSubscriptionToServer()');
var mergedEndpoint = endpointWorkaround(subscription);
/* サーバーに各種情報を送信 (今回は画面表示のため、処理なし)*/
/* 画面表示 */
showCurlCommand(mergedEndpoint);
showSubscriptionInfo(subscription);
}
/* エンドポイントの情報生成 */
function endpointWorkaround(pushSubscription) {
if (pushSubscription.endpoint.indexOf('https://android.googleapis.com/gcm/send') !== 0) {
return pushSubscription.endpoint;
}
var mergedEndpoint = pushSubscription.endpoint;
if (pushSubscription.subscriptionId &&
pushSubscription.endpoint.indexOf(pushSubscription.subscriptionId) === -1) {
mergedEndpoint = pushSubscription.endpoint + '/' +
pushSubscription.subscriptionId;
}
return mergedEndpoint;
}
/* UnSubscribe */
function UnSubscribePushNotification() {
navigator.serviceWorker.ready.then(ServiceWorkerUnregist);
}
function ServiceWorkerUnregist(serviceWorkerRegistration) {
/* UnSubscription 処理の開始前処理を記述 */
var subscribe = serviceWorkerRegistration.pushManager.getSubscription();
subscribe.then(UnSubscriptionProc);
}
function UnSubscriptionProc(subscription) {
if (!subscription) {
return;
}
subscription.unsubscribe().then(UnSubscriptionComplete);
}
function UnSubscriptionComplete() {
/* UnSubscription 処理完了後の処理を記述 */
}
/* 情報の画面表示 */
function showCurlCommand(mergedEndpoint) {
if (mergedEndpoint.indexOf(GCM_ENDPOINT) !== 0) {
window.Demo.debug.log('This browser isn\'t currently ' +
'supported for this demo');
return;
}
var endpointSections = mergedEndpoint.split('/');
var subscriptionId = endpointSections[endpointSections.length - 1];
var curlCommand = 'curl --header "Authorization: key=' + API_KEY +
'" --header Content-Type:"application/json" ' + GCM_ENDPOINT +
' -d "{\\"registration_ids\\":[\\"' + subscriptionId + '\\"]}"';
var frame = document.getElementById("command");
frame.innerText = curlCommand;
}
function showSubscriptionInfo(subscription) {
console.log(subscription);
console.log(subscription.getKey('p256dh'));
console.log(subscription.getKey('auth'));
var frame = document.getElementById("info");
frame.innerHTML = "Endpoint : " + subscription.endpoint + "<br/>";
frame.innerHTML += "p256dh Key : " + btoa(String.fromCharCode.apply(null, new Uint8Array(subscription.getKey('p256dh')))) + "<br/>";
frame.innerHTML += "Authentication Secret : " + btoa(String.fromCharCode.apply(null, new Uint8Array(subscription.getKey('auth')))) + "<br/>";
}
</script>
</head>
<body onload="onLoad();">
<p>Demo Page</p>
<a href="#" onclick="SubscribePushNotification();">プッシュ通知の購読</a><br />
<a href="#" onclick="UnSubscribePushNotification();">プッシュ通知の購読解除</a><br />
<hr />
<p id="command"></p>
<hr />
<p id="info"></p>
</body>
</html>
manifest.json
{
"name": "Data Payload Push Demo",
"short_name": "Data Payload Push Demo",
"icons": [
{
"src": "/demo/push/payload-data/images/icon-192x192.png",
"sizes": "192x192"
}
],
"start_url": "./index.html",
"display": "standalone",
"gcm_sender_id": "540000000068"
}
service-worker.js
'use strict';
self.addEventListener('push', function(event) {
console.log('Received a push message', event);
var title = 'メッセージのタイトル';
var body = 'プッシュメッセージを受信';
var icon = '/demo/push/payload-data/images/icon-192x192.png';
var tag = 'simple-data-payload-push-demo-notification-tag';
console.log('receive Data: ', event.data);
if (event.data != null) {
var textdata = event.data.text();
console.log('receive text: ', textdata);
body = body + ":" + textdata;
}
event.waitUntil(
self.registration.showNotification(title, {
body: body,
icon: icon,
tag: tag
})
);
});
self.addEventListener('notificationclick', function(event) {
console.log('On notification click: ', event.notification.tag);
event.notification.close();
event.waitUntil(clients.matchAll({
type: 'window'
}).then(function(clientList) {
for (var i = 0; i < clientList.length; i++) {
var client = clientList[i];
if (client.url === '/' && 'focus' in client) {
return client.focus();
}
}
if (clients.openWindow) {
return clients.openWindow('/');
}
}));
});
キーの設定
index.html
var API_KEY = 'AIza..............................xw2ns';
の部分には、Firebase の [ウェブアプリに Firebase を追加]ダイアログで取得できるJavaScriptコードのapiKeyの値、(1)の値を記載します。
manifest.json
"gcm_sender_id": "540000000068"
の部分には、Firebase の [ウェブアプリに Firebase を追加]ダイアログで取得できるJavaScriptコードのmessagingSenderIdの値、(2)の値を記載します。
Firebaseの管理画面で取得できる 設定JavaScriptコード
<script src="https://www.gstatic.com/firebasejs/3.4.1/firebase.js"></script>
<script>
// Initialize Firebase
var config = {
apiKey: " (1) ",
authDomain: "ipentecdemo.firebaseapp.com",
databaseURL: "https://ipentecdemo.firebaseio.com",
storageBucket: "ipentecdemo.appspot.com",
messagingSenderId: " (2) "
};
firebase.initializeApp(config);
</script>
解説
index.html, manifest.json については、画像の配置パス以外は「
Firebase (FCM)を利用して Google Chrome にプッシュ通知を送信する」で紹介しているコードと同じものです。動作も同様となっています。
service-worker.jsがサーバーからのプッシュメッセージを受け取るための変更があります。
if (event.data != null) {
var textdata = event.data.text();
console.log('receive text: ', textdata);
body = body + ":" + textdata;
}
プッシュを受け取りpushイベントが発生した際に、引数のeventオブジェクトのdataを確認します。dataがnullでなければ、サーバーからのデータが存在すると判定します。今回のプログラムでは、サーバーからテキスト形式の文字列が送信される仕様のため、text()メソッドを呼び出してデータの文字列を取得します。取得した文字列をbody変数の後ろに追加し、プッシュ通知のポップアップウィンドウに受信したメッセージを表示します。
配置
今回は "https://www.ipentec.com/demo/push/payload-data/" のディレクトリにファイルを配置します。
プログラム : プッシュサーバー
プッシュ通知を送信するプッシュサーバーを実装します。
サーバーからデータを付加して送信する場合、送信するデータの暗号化が必要になります。暗号化は、「楕円曲線ディフィー・ヘルマン鍵共有(ECDH)」と呼ばれる暗号化方式を利用します。
事前準備
今回は「楕円曲線ディフィー・ヘルマン鍵共有(ECDH)」を実装したライブラリを利用します。
https://www.bouncycastle.org/ からC#版のbccryptoをダウンロードします。ダウンロードしたアセンブリを参照に追加します。詳しくは
こちらの記事を参照してください。
FormDataPayloadPhsh
下図のWindowsフォームを作成します。
FormDataPayloadPush.cs
下記のコードを記述します。上図のフォームに配置した、ButtonのClickイベントを実装するのみです。
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace PushServerApplication
{
public partial class FormDataPayloadPush : Form
{
public FormDataPayloadPush()
{
InitializeComponent();
}
private void label2_Click(object sender, EventArgs e)
{
}
private void button1_Click(object sender, EventArgs e)
{
string Endpoint = textBox_endpoint.Text.Trim();
string SendMessage = textBox_message.Text.Trim();
string p256dhKey = textBox_p256dhKey.Text.Trim();
string AuthSecret = textBox_authsecret.Text.Trim();
bool ret = WebPushHelper.SendNotification(Endpoint, SendMessage, p256dhKey, AuthSecret);
textBox_output.Text += ret.ToString();
}
}
}
WebPushHelper.cs
下記のクラスを作成します。
using Org.BouncyCastle.Asn1.X9;
using Org.BouncyCastle.Crypto;
using Org.BouncyCastle.Crypto.Agreement;
using Org.BouncyCastle.Crypto.Generators;
using Org.BouncyCastle.Crypto.Parameters;
using Org.BouncyCastle.Math;
using Org.BouncyCastle.Security;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
namespace PushServerApplication
{
public class WebPushHelper
{
private const string FirebaseServerKey = "AIza.............................tHVY";
public static bool SendNotification(JsonSubscription sub, byte[] data, int ttl = 0, ushort padding = 0,
bool randomisePadding = false)
{
return SendNotification(endpoint: sub.endpoint,
data: data,
userKey: Base64ForUrlDecode(sub.keys["p256dh"]),
userSecret: Base64ForUrlDecode(sub.keys["auth"]),
ttl: ttl,
padding: padding,
randomisePadding: randomisePadding);
}
public static bool SendNotification(string endpoint, string datastr, string userKey, string userSecret,
int ttl = 0, ushort padding = 0, bool randomisePadding = false)
{
return SendNotification(endpoint: endpoint,
data: Encoding.UTF8.GetBytes(datastr),
userKey: Base64ForUrlDecode(userKey),
userSecret: Base64ForUrlDecode(userSecret),
ttl: ttl,
padding: padding,
randomisePadding: randomisePadding);
}
public static bool SendNotification(string endpoint, byte[] userKey, byte[] userSecret, byte[] data = null,
int ttl = 0, ushort padding = 0, bool randomisePadding = false)
{
HttpRequestMessage Request = new HttpRequestMessage(HttpMethod.Post, endpoint);
if (endpoint.StartsWith("https://android.googleapis.com/gcm/send/")) {
Request.Headers.TryAddWithoutValidation("Authorization", "key=" + FirebaseServerKey);
}
Request.Headers.Add("TTL", ttl.ToString());
if (data != null && userKey != null && userSecret != null) {
EncryptionResult Package = EncryptMessage(userKey, userSecret, data, padding, randomisePadding);
Request.Content = new ByteArrayContent(Package.Payload);
Request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
Request.Content.Headers.ContentLength = Package.Payload.Length;
Request.Content.Headers.ContentEncoding.Add("aesgcm");
Request.Headers.Add("Crypto-Key", "keyid=p256dh;dh=" + Base64ForUrlEncode(Package.PublicKey));
Request.Headers.Add("Encryption", "keyid=p256dh;salt=" + Base64ForUrlEncode(Package.Salt));
}
using (HttpClient HC = new HttpClient()) {
HttpResponseMessage hrm = HC.SendAsync(Request).Result;
//return true;
return hrm.StatusCode == HttpStatusCode.Created;
}
}
public static EncryptionResult EncryptMessage(byte[] userKey, byte[] userSecret, byte[] data,
ushort padding = 0, bool randomisePadding = false)
{
SecureRandom Random = new SecureRandom();
byte[] Salt = new byte[16];
Random.NextBytes(Salt);
X9ECParameters Curve = ECNamedCurveTable.GetByName("prime256v1");
ECDomainParameters Spec = new ECDomainParameters(Curve.Curve, Curve.G, Curve.N, Curve.H, Curve.GetSeed());
ECKeyPairGenerator Generator = new ECKeyPairGenerator();
Generator.Init(new ECKeyGenerationParameters(Spec, new SecureRandom()));
AsymmetricCipherKeyPair KeyPair = Generator.GenerateKeyPair();
ECDHBasicAgreement AgreementGenerator = new ECDHBasicAgreement();
AgreementGenerator.Init(KeyPair.Private);
BigInteger IKM = AgreementGenerator.CalculateAgreement(new ECPublicKeyParameters(Spec.Curve.DecodePoint(userKey), Spec));
byte[] PRK = GenerateHKDF(userSecret, IKM.ToByteArrayUnsigned(), Encoding.UTF8.GetBytes("Content-Encoding: auth\0"), 32);
byte[] PublicKey = ((ECPublicKeyParameters)KeyPair.Public).Q.GetEncoded(false);
byte[] CEK = GenerateHKDF(Salt, PRK, CreateInfoChunk("aesgcm", userKey, PublicKey), 16);
byte[] Nonce = GenerateHKDF(Salt, PRK, CreateInfoChunk("nonce", userKey, PublicKey), 12);
if (randomisePadding && padding > 0) padding = Convert.ToUInt16(Math.Abs(Random.NextInt()) % (padding + 1));
byte[] Input = new byte[padding + 2 + data.Length];
Buffer.BlockCopy(ConvertInt(padding), 0, Input, 0, 2);
Buffer.BlockCopy(data, 0, Input, padding + 2, data.Length);
IBufferedCipher Cipher = CipherUtilities.GetCipher("AES/GCM/NoPadding");
Cipher.Init(true, new AeadParameters(new KeyParameter(CEK), 128, Nonce));
byte[] Message = new byte[Cipher.GetOutputSize(Input.Length)];
Cipher.DoFinal(Input, 0, Input.Length, Message, 0);
return new EncryptionResult() { Salt = Salt, Payload = Message, PublicKey = PublicKey };
}
public class EncryptionResult
{
public byte[] PublicKey { get; set; }
public byte[] Payload { get; set; }
public byte[] Salt { get; set; }
}
public class JsonSubscription
{
public string endpoint { get; set; }
public Dictionary<string, string> keys { get; set; }
}
public static byte[] ConvertInt(int number)
{
byte[] Output = BitConverter.GetBytes(Convert.ToUInt16(number));
if (BitConverter.IsLittleEndian) Array.Reverse(Output);
return Output;
}
public static byte[] CreateInfoChunk(string type, byte[] recipientPublicKey, byte[] senderPublicKey)
{
List<byte> Output = new List<byte>();
Output.AddRange(Encoding.UTF8.GetBytes($"Content-Encoding: {type}\0P-256\0"));
Output.AddRange(ConvertInt(recipientPublicKey.Length));
Output.AddRange(recipientPublicKey);
Output.AddRange(ConvertInt(senderPublicKey.Length));
Output.AddRange(senderPublicKey);
return Output.ToArray();
}
public static byte[] GenerateHKDF(byte[] salt, byte[] ikm, byte[] info, int len)
{
IMac PRKGen = MacUtilities.GetMac("HmacSHA256");
PRKGen.Init(new KeyParameter(MacUtilities.CalculateMac("HmacSHA256", new KeyParameter(salt), ikm)));
PRKGen.BlockUpdate(info, 0, info.Length);
PRKGen.Update((byte)1);
byte[] Result = MacUtilities.DoFinal(PRKGen);
if (Result.Length > len) Array.Resize(ref Result, len);
return Result;
}
public static string Base64ForUrlEncode(byte[] str)
{
string returnValue = System.Convert.ToBase64String(str);
returnValue = returnValue.TrimEnd().Replace('+', '-').Replace('/', '_');
return returnValue;
}
public static byte[] Base64ForUrlDecode(string str)
{
byte[] data = Convert.FromBase64String(str);
return data;
}
}
}
キーの設定
FirebaseServerKey に Firebaseのサーバーキーを指定します。サーバーキーはFirebaseの設定画面の[クラウドメッセージング]の[サーバーキー]の値を利用します。
解説
暗号化以外の送信処理は、「
Firebase (FCM)を利用して Google Chrome にプッシュ通知を送信する」の動作と同じになります。
文字コード
日本語を送信した場合でも文字化けしないように、文字列をバイト変換する際にはUTF8形式でバイト配列に変換します。ASCIIやSJISのバイト配列では文字化けが発生します。
public static bool SendNotification(string endpoint, string datastr, string userKey, string userSecret,
int ttl = 0, ushort padding = 0, bool randomisePadding = false)
{
return SendNotification(endpoint: endpoint,
data: Encoding.UTF8.GetBytes(datastr),
userKey: Base64ForUrlDecode(userKey),
userSecret: Base64ForUrlDecode(userSecret),
ttl: ttl,
padding: padding,
randomisePadding: randomisePadding);
}
暗号化処理
暗号化処理は、以下の処理を実行します。
SecureRandom Random = new SecureRandom();
byte[] Salt = new byte[16];
Random.NextBytes(Salt);
Saltと呼ばれる16バイトの乱数を作成します。
X9ECParameters Curve = ECNamedCurveTable.GetByName("prime256v1");
ECDomainParameters Spec = new ECDomainParameters(Curve.Curve, Curve.G, Curve.N, Curve.H, Curve.GetSeed());
ECKeyPairGenerator Generator = new ECKeyPairGenerator();
Generator.Init(new ECKeyGenerationParameters(Spec, new SecureRandom()));
AsymmetricCipherKeyPair KeyPair = Generator.GenerateKeyPair();
にてキーペアを作成します。キーペアを作成するにあたり、X9ECParameters の準備とECDomainParameters の準備が必要です。
以下の処理で必要となる値を求めます。
ECDHBasicAgreement AgreementGenerator = new ECDHBasicAgreement();
AgreementGenerator.Init(KeyPair.Private);
BigInteger IKM = AgreementGenerator.CalculateAgreement(new ECPublicKeyParameters(Spec.Curve.DecodePoint(userKey), Spec));
IKM値を求めます。
byte[] PRK = GenerateHKDF(userSecret, IKM.ToByteArrayUnsigned(), Encoding.UTF8.GetBytes("Content-Encoding: auth\0"), 32);
IKM値とuserSecret(Auth)キーからPRK値を求めます。
byte[] PublicKey = ((ECPublicKeyParameters)KeyPair.Public).Q.GetEncoded(false);
先に生成したキーペアから公開鍵をバイト配列として取り出します。
byte[] CEK = GenerateHKDF(Salt, PRK, CreateInfoChunk("aesgcm", userKey, PublicKey), 16);
Salt(乱数値)とPRK値、生成した公開鍵、p256dhキー(公開鍵)を利用して、CEK値を求めます。これが、AES-GCMでの暗号鍵(128ビット)となります。
byte[] Nonce = GenerateHKDF(Salt, PRK, CreateInfoChunk("nonce", userKey, PublicKey), 12);
Salt(乱数値)とPRK値、生成した公開鍵、p256dhキー(公開鍵)を用いてnonceを求めます。
if (randomisePadding && padding > 0) padding = Convert.ToUInt16(Math.Abs(Random.NextInt()) % (padding + 1));
byte[] Input = new byte[padding + 2 + data.Length];
Buffer.BlockCopy(ConvertInt(padding), 0, Input, 0, 2);
Buffer.BlockCopy(data, 0, Input, padding + 2, data.Length);
送信データを暗号化する準備処理です。暗号化する文字列をバイト配列に展開します。
IBufferedCipher Cipher = CipherUtilities.GetCipher("AES/GCM/NoPadding");
Cipher.Init(true, new AeadParameters(new KeyParameter(CEK), 128, Nonce));
byte[] Message = new byte[Cipher.GetOutputSize(Input.Length)];
Cipher.DoFinal(Input, 0, Input.Length, Message, 0);
return new EncryptionResult() { Salt = Salt, Payload = Message, PublicKey = PublicKey };
CEK値(AES-GCMでの暗号鍵)を利用して暗号化します。戻り値には、Salt値、暗号化した送信データ、生成したキーペアの公開鍵を返します。
送信処理
送信時にはヘッダに"Authorization"キーを追加します。値には "key=(サーバーキー)" を設定します。
Request.Headers.TryAddWithoutValidation("Authorization", "key=" + FirebaseServerKey);
POSTするデータは、暗号化されたメッセージを送信します。ContentType には"application/octet-stream"を指定します。
Request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
また暗号化されていることを示すため、ContentEncodingには "aesgcm"を指定します。
Request.Content.Headers.ContentEncoding.Add("aesgcm");
暗号化を解除するキーをヘッダで送信します。"Crypto-Key"に公開鍵を設定し、"Encryption"にSalt値を設定します。
Request.Headers.Add("Crypto-Key", "keyid=p256dh;dh=" + Base64ForUrlEncode(Package.PublicKey));
Request.Headers.Add("Encryption", "keyid=p256dh;salt=" + Base64ForUrlEncode(Package.Salt));
実行結果
ファイルを配置したURLにアクセスします。下図のページが表示されます。[プッシュ通知の購読]をクリックします。
購読処理が完了すると、curlのコマンドとEndpointなどの情報が表示されます。
続いてプッシュサーバー側のプロジェクトを実行します。下図のウィンドウが表示されます。
購読処理完了時にWebブラウザに表示された、p256dh Keyをフォームのp256dh Keyのテキストボックスにコピーペーストで入力します。同様の手順でAuthentication Secret, Endpoint もコピーペーストで入力します。プッシュで送信したいメッセージを送信メッセージのテキストボックスに入力します。入力後[Push]ボタンをクリックします。
Chromeを開いているPCの画面右下にプッシュ通知のメッセージが表示されます。プッシュ通知の本文にサーバー側で設定したテキストの文字列が表示されていることが確認できます。
以上でサーバー側からプッシュ通知でメッセージを送ることができました。
拡張について
今回はメッセージ本文のみを通知しましたが、メッセージのタイトルやアイコン画像を変更したい場合は、文字列ではなくJSON形式のデータを送信し、service-worker.jsで
var textdata = event.data.text();
のテキスト受信ではなく、
var dataobj = event.data.json();
として、JSON形式のオブジェクトとして受信すると、複数の構造化されたデータを受信できます。
参考URL
http://stackoverflow.com/questions/38162169/is-there-a-way-for-webpush-payload-encryption-in-net
著者
iPentecのメインプログラマー
C#, ASP.NET の開発がメイン、少し前まではDelphiを愛用
最終更新日: 2024-01-06
作成日: 2016-10-09