会员服务器:文档补齐 v1.5.1(README 版本/功能更新,补缺失的 CHANGELOG v1.5.1 条目)
README:产物名 1.4.0→1.5.1;开头补当前版本说明与 1.5.0/1.5.1 特性; 项目云管理页补「删除选中」批量删除(1.5.1 新增,此前未记录); 修正过期的「已知边界:传输为 HTTP」(1.4.0 起已全站 HTTPS,仅剩自签证书告警边界)。 CHANGELOG:补写缺失的 v1.5.1 条目(此前最高只到 v1.5.0)。 新增首次上传时因 root 000 权限漏掉的 客户端对接示例.cs; 示例注释中的真实手机号/密码替换为占位符(仓库公开可读)。
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
// ============================================================================
|
||||
// 会员服务器 · 客户端加密通讯参考实现 (C# / .NET Framework 3.5+,零第三方依赖)
|
||||
// ============================================================================
|
||||
// 用法概览(配合 fnnas.huiyuan v1.4.0 全站 HTTPS + 信封加密):
|
||||
// var cli = new HuiyuanSecureClient("https://192.168.9.21:12701");
|
||||
// string resp = cli.EncryptedCall("/api/login", "{\"手机号\":\"138...\",\"密码\":\"xx\"}");
|
||||
// cli.UploadProject("138...", "密码", "20260917001", "项目名", zipBytes);
|
||||
// byte[] zip = cli.DownloadProject("138...", "密码", "20260917001");
|
||||
//
|
||||
// 加密协议(与服务器 /api/enc 对应):
|
||||
// 1. GET /api/pubkey → {"ok":true,"pubkey":"-----BEGIN PUBLIC KEY..."}
|
||||
// 2. 每次调用:
|
||||
// aesKey = 随机 32 字节; iv = 随机 16 字节; nonce = 随机 16 字节 hex
|
||||
// data = BASE64( AES-256-CBC( iv, 业务JSON ) ) [PKCS7 填充]
|
||||
// k = BASE64( RSA-OAEP-SHA1( 公钥, aesKey ) )
|
||||
// mac = HEX( HMAC-SHA256( aesKey, ts + "." + nonce + "." + iv + "." + data ) )
|
||||
// POST /api/enc {"k":k,"iv":iv,"ts":毫秒时间戳,"nonce":nonce,"mac":mac,"data":data}
|
||||
// 3. 响应: {"ok":true,"iv":hex,"mac":HEX( HMAC( aesKey, iv+"."+data ) ),"data":BASE64(AES(结果JSON))}
|
||||
// → AES 解密得到业务结果(与直连 HTTP 时代的接口字段完全一致)
|
||||
// 注意:
|
||||
// · 业务字段与旧协议完全一致,只是外面套了加密信封
|
||||
// · 首次使用请先在浏览器打开 https://NAS:12701/settings.html 下载证书并安装到
|
||||
// "受信任的根证书颁发机构"(或保留下面的证书校验跳过回调)
|
||||
// ============================================================================
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
|
||||
namespace HuiyuanClient
|
||||
{
|
||||
public class HuiyuanSecureClient
|
||||
{
|
||||
private string _baseUrl; // 如 https://192.168.9.21:12701
|
||||
private string _pubKeyPem; // 服务器 RSA 公钥(PEM)
|
||||
private RSACryptoServiceProvider _rsa;
|
||||
|
||||
static HuiyuanSecureClient()
|
||||
{
|
||||
// 信任自签证书(已安装服务器证书到受信任根的可删除此回调)
|
||||
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;
|
||||
}
|
||||
|
||||
public HuiyuanSecureClient(string baseUrl)
|
||||
{
|
||||
_baseUrl = baseUrl.TrimEnd('/');
|
||||
}
|
||||
|
||||
// ---------- 公开方法 ----------
|
||||
|
||||
/// <summary>初始化:拉取服务器 RSA 公钥(每次程序启动调用一次)</summary>
|
||||
public void Init()
|
||||
{
|
||||
string json = HttpRaw("GET", _baseUrl + "/api/pubkey", null, null);
|
||||
_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
|
||||
byte[] aesKey = new byte[32]; Random.NextBytes(aesKey);
|
||||
byte[] iv = new byte[16]; Random.NextBytes(iv);
|
||||
byte[] nonce = new byte[16]; Random.NextBytes(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", envelope,
|
||||
new Dictionary<string, string> { { "Content-Type", "application/json" } });
|
||||
|
||||
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)
|
||||
{
|
||||
string head = "{\"手机号\":\"" + phone + "\",\"编号\":\"" + code + "\",\"名称\":\"" + name
|
||||
+ "\",\"密码\":\"" + 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" } });
|
||||
}
|
||||
|
||||
/// <summary>下载项目 zip(走 TLS 直连)</summary>
|
||||
public byte[] DownloadProject(string phone, string password, string code)
|
||||
{
|
||||
string url = _baseUrl + "/api/proj/download?手机号=" + Uri.EscapeDataString(phone)
|
||||
+ "&编号=" + Uri.EscapeDataString(code)
|
||||
+ "&密码=" + Uri.EscapeDataString(password);
|
||||
using (WebClient wc = new WebClient()) { return wc.DownloadData(url); }
|
||||
}
|
||||
|
||||
// ---------- 内部工具 ----------
|
||||
|
||||
private static readonly Random Random = new Random();
|
||||
|
||||
private string HttpRaw(string method, string url, byte[] body, Dictionary<string, string> headers)
|
||||
{
|
||||
HttpWebRequest req = (HttpWebRequest)WebRequest.Create(url);
|
||||
req.Method = method;
|
||||
req.Timeout = 30000;
|
||||
if (headers != null) foreach (KeyValuePair<string, string> kv in headers) 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 }
|
||||
// 定位 BIT STRING,内层为 PKCS#1 RSAPublicKey
|
||||
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 取顶层字符串值(本协议响应结构固定,够用;复杂解析建议用 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++; sb.Append(json[i] == 'n' ? '\n' : json[i]); }
|
||||
else sb.Append(json[i]);
|
||||
i++;
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
// ======================= 调用示例 =======================
|
||||
// class Demo {
|
||||
// static void Main() {
|
||||
// var cli = new HuiyuanSecureClient("https://192.168.9.21:12701");
|
||||
// cli.Init();
|
||||
// // 登录(信封加密)
|
||||
// Console.WriteLine(cli.EncryptedCall("/api/login",
|
||||
// "{\"手机号\":\"示例手机号\",\"密码\":\"示例密码\"}"));
|
||||
// // 项目云列表(信封加密)
|
||||
// Console.WriteLine(cli.EncryptedCall("/api/proj/list",
|
||||
// "{\"手机号\":\"示例手机号\",\"密码\":\"示例密码\"}"));
|
||||
// // 上传项目 zip(TLS 直连)
|
||||
// // Console.WriteLine(cli.UploadProject("示例手机号", "示例密码", "20260917001", "项目名", File.ReadAllBytes("d:/x.zip")));
|
||||
// // 下载项目 zip(TLS 直连)
|
||||
// // byte[] zip = cli.DownloadProject("示例手机号", "示例密码", "20260917001");
|
||||
// }
|
||||
// }
|
||||
}
|
||||
Reference in New Issue
Block a user