Files
ctbjrj/成套报价软件/会员安全客户端.cs

320 lines
16 KiB
C#

// ============================================================================
// 会员服务器 · 加密通讯客户端(照 fnnas.huiyuan v1.4.0 官方参考实现 客户端对接示例.cs 拷入)
// · 命名空间改为本项目的 成套报价软件;地址单源化:构造时取 会员服务Host.基地址
// · 协议:Init 拉 /api/pubkey → 每次业务调用 AES-256-CBC+RSA-OAEP 信封 POST /api/enc
// 上传/下载项目走 TLS 直连(head/URL 带 密码 参数)
// · 服务端版本要求 >= v1.4.0(老明文服务不支持)
// ============================================================================
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Security;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
namespace 成套报价软件
{
public class 会员安全客户端
{
private string _baseUrl; // 如 https://192.168.9.21:12701
private string _pubKeyPem; // 服务器 RSA 公钥(PEM)
private RSACryptoServiceProvider _rsa;
static 会员安全客户端()
{
// 信任自签证书(服务器证书已装受信任根的场景同样兼容——回调只在没有其他校验失败原因时放行也行,这里按参考实现全放行)
ServicePointManager.ServerCertificateValidationCallback =
(object s, X509Certificate cert, X509Chain chain, SslPolicyErrors err) => true;
// 启用 TLS 1.2(值 3072;老系统默认只开 TLS1.0)
try { ServicePointManager.SecurityProtocol = (SecurityProtocolType)3072; } catch { }
ServicePointManager.Expect100Continue = false;
}
/// <summary>baseUrl 如 https://192.168.9.21:12701(取 会员服务Host.基地址,单源)。</summary>
public 会员安全客户端(string baseUrl)
{
_baseUrl = baseUrl.TrimEnd('/');
}
// ---------- 公开方法 ----------
/// <summary>初始化:拉取服务器 RSA 公钥(每次程序启动调用一次)。</summary>
public void Init()
{
string json = HttpRaw("GET", _baseUrl + "/api/pubkey", null, null, 30000);
_pubKeyPem = ExtractString(json, "pubkey");
if (string.IsNullOrEmpty(_pubKeyPem)) throw new Exception("获取服务器公钥失败: " + json);
_rsa = PemToRsa(_pubKeyPem);
}
/// <summary>加密调用业务接口(登录/注册/查会员/改密/项目云列表/删除)。
/// body 为 JSON 字符串,返回解密后的结果 JSON 字符串。</summary>
public string EncryptedCall(string apiPath, string bodyJson)
{
if (_rsa == null) throw new Exception("请先调用 Init()");
// 1. 组内层业务报文
string inner = "{\"path\":\"" + apiPath + "\",\"body\":" + (bodyJson == null ? "null" : bodyJson) + "}";
// 2. 随机 AES-256 密钥 + IV + nonce(CSPRNG:密钥材料必须不可预测)
byte[] aesKey = new byte[32]; 随机数.GetBytes(aesKey);
byte[] iv = new byte[16]; 随机数.GetBytes(iv);
byte[] nonce = new byte[16]; 随机数.GetBytes(nonce);
string nonceHex = ToHex(nonce);
// 3. AES 加密业务数据
string data = ToBase64(AesEncrypt(Encoding.UTF8.GetBytes(inner), aesKey, iv));
// 4. RSA-OAEP 封装 AES 密钥
string k = ToBase64(_rsa.Encrypt(aesKey, true)); // true = OAEP-SHA1
// 5. HMAC 签名: ts + "." + nonce + "." + iv + "." + data
string ts = DateTimeOffset.Now.ToUnixTimeMilliseconds().ToString();
string ivHex = ToHex(iv);
string mac = HmacHex(aesKey, ts + "." + nonceHex + "." + ivHex + "." + data);
string envelope = "{\"k\":\"" + k + "\",\"iv\":\"" + ivHex + "\",\"ts\":" + ts
+ ",\"nonce\":\"" + nonceHex + "\",\"mac\":\"" + mac + "\",\"data\":\"" + data + "\"}";
// 6. 发送并解密响应
string respJson = HttpRaw("POST", _baseUrl + "/api/enc", Encoding.UTF8.GetBytes(envelope),
new Dictionary<string, string> { { "Content-Type", "application/json" } }, 30000);
if (respJson.IndexOf("\"ok\":true") != 0 && respJson.IndexOf("{\"ok\":true") != 0)
return respJson; // 信封层错误(时间戳过期/重放/签名失败等),原文返回给上层判断
string respIv = ExtractString(respJson, "iv");
string respMac = ExtractString(respJson, "mac");
string respData = ExtractString(respJson, "data");
string wantMac = HmacHex(aesKey, respIv + "." + respData);
if (wantMac != respMac) throw new Exception("响应签名校验失败(数据可能被篡改)");
return Encoding.UTF8.GetString(AesDecrypt(FromBase64(respData), aesKey, FromHex(respIv)));
}
/// <summary>上传项目/库 zip(TLS 直连,body = JSON头 + \n\n + zip 二进制;头含 密码)。</summary>
public string UploadProject(string phone, string password, string code, string name, byte[] zipBytes)
{
phone = (phone ?? "").Trim();
password = password ?? "";
code = (code ?? "").Trim();
云请求日志("上传", phone, password, code);
if (password.Trim().Length == 0)
throw new Exception("登录凭证缺失,请退出软件重新登录后再试(云操作需要登录密码)");
string head = "{\"手机号\":\"" + JsonEsc(phone) + "\",\"编号\":\"" + JsonEsc(code) + "\",\"名称\":\"" + JsonEsc(name)
+ "\",\"密码\":\"" + JsonEsc(password) + "\",\"时间\":\""
+ DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + "\"}";
byte[] headB = Encoding.UTF8.GetBytes(head + "\n\n");
byte[] all = new byte[headB.Length + zipBytes.Length];
Array.Copy(headB, 0, all, 0, headB.Length);
Array.Copy(zipBytes, 0, all, headB.Length, zipBytes.Length);
return HttpRaw("POST", _baseUrl + "/api/proj/upload", all,
new Dictionary<string, string> { { "Content-Type", "application/octet-stream" } }, 120000);
}
/// <summary>下载项目/库 zip(TLS 直连,URL 带 密码)。
/// ★ 错误响应校验:服务端对"云上没有该项目/密码错"等返回 HTTP 200+JSON 错误体,
/// 原样存盘会变成假 zip(解压报"找不到中央目录结尾记录")——这里校验 PK 头,
/// 非 zip 时抛出服务器真实错误信息。</summary>
public byte[] DownloadProject(string phone, string password, string code)
{
phone = (phone ?? "").Trim();
password = password ?? "";
code = (code ?? "").Trim();
云请求日志("下载", phone, password, code);
if (password.Trim().Length == 0)
throw new Exception("登录凭证缺失,请退出软件重新登录后再试(云操作需要登录密码)");
string url = _baseUrl + "/api/proj/download?手机号=" + Uri.EscapeDataString(phone)
+ "&编号=" + Uri.EscapeDataString(code)
+ "&密码=" + Uri.EscapeDataString(password);
byte[] data;
using (WebClient wc = new WebClient())
{
wc.Proxy = null;
data = wc.DownloadData(url);
}
if (data == null || data.Length < 4 || data[0] != 0x50 || data[1] != 0x4B) // "PK"
{
// 非 zip:提取 JSON 里的 msg 给出真实原因
string 文本 = data == null ? "" : Encoding.UTF8.GetString(data).Trim();
string msg = ExtractString(文本, "msg");
throw new Exception(string.IsNullOrEmpty(msg)
? "服务器返回的不是项目包" + (文本.Length > 0 && 文本.Length < 200 ? ": " + 文本 : "")
: msg);
}
return data;
}
// ---------- 内部工具 ----------
/// <summary>云请求诊断日志(不记密码明文):手机号掩码+密码长度+是否含空白字符+编号。
/// 定位"手机号或密码错误"类问题的实发参数。</summary>
private static void 云请求日志(string 动作, string phone, string password, string code)
{
try
{
string 掩码 = phone.Length >= 7
? phone.Substring(0, 3) + "****" + phone.Substring(phone.Length - 4)
: (phone.Length == 0 ? "(空)" : phone);
bool 含空白 = password.Length != password.Trim().Length || password.IndexOfAny(new[] { ' ', '\r', '\n', '\t' }) >= 0;
MainForm.写日志("[云请求] " + 动作 + " 手机号=" + 掩码 + "(长" + phone.Length + ")"
+ " 密码长=" + password.Length + (含空白 ? " ★含空白字符!" : "")
+ " 编号=" + code);
}
catch { }
}
/// <summary>加密安全随机数(AES 会话密钥/IV/nonce 的熵源)。</summary>
private static readonly RNGCryptoServiceProvider 随机数 = new RNGCryptoServiceProvider();
/// <summary>最小 JSON 字符串转义(头里的名称/路径可能含中文引号外字符)。</summary>
private static string JsonEsc(string s)
{
if (s == null) return "";
return s.Replace("\\", "\\\\").Replace("\"", "\\\"")
.Replace("\r", "\\r").Replace("\n", "\\n").Replace("\t", "\\t");
}
private string HttpRaw(string method, string url, byte[] body, Dictionary<string, string> headers, int timeoutMs)
{
HttpWebRequest req = (HttpWebRequest)WebRequest.Create(url);
req.Method = method;
req.Timeout = timeoutMs;
req.ReadWriteTimeout = timeoutMs;
req.Proxy = null;
if (headers != null)
{
foreach (KeyValuePair<string, string> kv in headers)
{
// ★ Content-Type 必须走专用属性(HttpWebRequest 禁止经 Headers 集合改它)
if (string.Equals(kv.Key, "Content-Type", StringComparison.OrdinalIgnoreCase))
req.ContentType = kv.Value;
else
req.Headers[kv.Key] = kv.Value;
}
}
if (body != null)
{
req.ContentLength = body.Length;
using (Stream s = req.GetRequestStream()) s.Write(body, 0, body.Length);
}
using (HttpWebResponse resp = (HttpWebResponse)req.GetResponse())
using (StreamReader r = new StreamReader(resp.GetResponseStream(), Encoding.UTF8))
return r.ReadToEnd();
}
private static byte[] AesEncrypt(byte[] plain, byte[] key, byte[] iv)
{
using (Aes aes = Aes.Create())
{
aes.KeySize = 256; aes.Mode = CipherMode.CBC; aes.Padding = PaddingMode.PKCS7;
aes.Key = key; aes.IV = iv;
using (ICryptoTransform enc = aes.CreateEncryptor()) return enc.TransformFinalBlock(plain, 0, plain.Length);
}
}
private static byte[] AesDecrypt(byte[] cipher, byte[] key, byte[] iv)
{
using (Aes aes = Aes.Create())
{
aes.KeySize = 256; aes.Mode = CipherMode.CBC; aes.Padding = PaddingMode.PKCS7;
aes.Key = key; aes.IV = iv;
using (ICryptoTransform dec = aes.CreateDecryptor()) return dec.TransformFinalBlock(cipher, 0, cipher.Length);
}
}
private static RSACryptoServiceProvider PemToRsa(string pem)
{
string b64 = pem.Replace("-----BEGIN PUBLIC KEY-----", "")
.Replace("-----END PUBLIC KEY-----", "")
.Replace("\r", "").Replace("\n", "").Trim();
byte[] der = Convert.FromBase64String(b64);
try { return RsaFromSpki(der); }
catch { throw new Exception("公钥解析失败,请确认服务器版本 >= v1.4.0"); }
}
/// <summary>解析 SubjectPublicKeyInfo(PKCS#1 内嵌)为 RSA(通用写法,2048 位)。</summary>
private static RSACryptoServiceProvider RsaFromSpki(byte[] der)
{
// SubjectPublicKeyInfo ::= { algorithm AlgorithmIdentifier, subjectPublicKey BIT STRING }
int i = 0;
if (der[i++] != 0x30) throw new Exception();
int len = der[i++]; if (len > 0x80) { int n = len & 0x7f; len = 0; for (int j = 0; j < n; j++) len = len * 256 + der[i++]; }
// AlgorithmIdentifier 跳过
if (der[i++] != 0x30) throw new Exception();
int alen = der[i++]; if (alen > 0x80) { int n = alen & 0x7f; alen = 0; for (int j = 0; j < n; j++) alen = alen * 256 + der[i++]; }
i += alen;
if (der[i++] != 0x03) throw new Exception(); // BIT STRING
int blen = der[i++]; if (blen > 0x80) { int n = blen & 0x7f; blen = 0; for (int j = 0; j < n; j++) blen = blen * 256 + der[i++]; }
i++; // 未使用位 0x00
// PKCS#1 RSAPublicKey ::= { modulus INTEGER, publicExponent INTEGER }
if (der[i++] != 0x30) throw new Exception();
int plen = der[i++]; if (plen > 0x80) { int n = plen & 0x7f; plen = 0; for (int j = 0; j < n; j++) plen = plen * 256 + der[i++]; }
if (der[i++] != 0x02) throw new Exception(); // modulus
int mlen = der[i++]; if (mlen > 0x80) { int n = mlen & 0x7f; mlen = 0; for (int j = 0; j < n; j++) mlen = mlen * 256 + der[i++]; }
byte[] modulus = new byte[mlen]; Array.Copy(der, i, modulus, 0, mlen); i += mlen;
if (modulus[0] == 0) { byte[] t = new byte[mlen - 1]; Array.Copy(modulus, 1, t, 0, mlen - 1); modulus = t; mlen--; }
if (der[i++] != 0x02) throw new Exception(); // exponent
int elen = der[i++]; if (elen > 0x80) { int n = elen & 0x7f; elen = 0; for (int j = 0; j < n; j++) elen = elen * 256 + der[i++]; }
byte[] exponent = new byte[elen]; Array.Copy(der, i, exponent, 0, elen);
RSAParameters p = new RSAParameters { Modulus = modulus, Exponent = exponent };
RSACryptoServiceProvider rsa = new RSACryptoServiceProvider(2048);
rsa.ImportParameters(p);
return rsa;
}
private static string HmacHex(byte[] key, string message)
{
using (HMACSHA256 h = new HMACSHA256(key))
return ToHex(h.ComputeHash(Encoding.UTF8.GetBytes(message)));
}
private static string ToHex(byte[] b)
{
StringBuilder sb = new StringBuilder(b.Length * 2);
foreach (byte x in b) sb.Append(x.ToString("x2"));
return sb.ToString();
}
private static byte[] FromHex(string s)
{
byte[] b = new byte[s.Length / 2];
for (int i = 0; i < b.Length; i++) b[i] = Convert.ToByte(s.Substring(i * 2, 2), 16);
return b;
}
private static string ToBase64(byte[] b) { return Convert.ToBase64String(b); }
private static byte[] FromBase64(string s) { return Convert.FromBase64String(s); }
/// <summary>从扁平 JSON 取顶层字符串值(本协议响应结构固定,够用)。</summary>
private static string ExtractString(string json, string key)
{
string k = "\"" + key + "\":\"";
int i = json.IndexOf(k, StringComparison.Ordinal);
if (i < 0) return null;
i += k.Length;
StringBuilder sb = new StringBuilder();
while (i < json.Length && json[i] != '"')
{
if (json[i] == '\\' && i + 1 < json.Length)
{
i++;
char n = json[i];
if (n == 'n') sb.Append('\n');
else if (n == 'u' && i + 4 < json.Length)
{
sb.Append((char)Convert.ToInt32(json.Substring(i + 1, 4), 16));
i += 4;
}
else sb.Append(n);
}
else sb.Append(json[i]);
i++;
}
return sb.ToString();
}
}
}