用Rust手把手編寫一個Proxy(代理), TLS加密通訊

問蒙服務框架發表於2023-09-25

用Rust手把手編寫一個Proxy(代理), TLS加密通訊

專案 ++wmproxy++

gite: https://gitee.com/tickbh/wmproxy

github: https://github.com/tickbh/wmproxy

為什麼選擇TLS

瞭解TLS

安全傳輸層協議(TLS)用於在兩個通訊應用程式之間提供保密性和資料完整性。
該協議由兩層組成: TLS 記錄協議(TLS Record)和 TLS 握手協議(TLS Handshake)。

TLS版本的歷程

版本 發表年份 RFC檔案 棄用年份 RFC連結
TLS1.0 1999年 RFC2246 2021年棄用 https://datatracker.ietf.org/doc/rfc2246/
TLS1.1 2006年 RFC4346 2021年棄用 https://datatracker.ietf.org/doc/rfc4346/
TLS1.2 2008年 RFC5246 正在使用 https://datatracker.ietf.org/doc/rfc5246/
TLS1.3 2018年 RFC8446 正在使用 https://datatracker.ietf.org/doc/rfc8446/

TLS協議的優勢是與高層的應用層協議(如HTTP、FTP、Telnet等)無耦合。應用層協議能透明地執行在TLS協議之上,由TLS協議進行建立加密通道需要的協商和認證。應用層協議傳送的資料在透過TLS協議時都會被加密,從而保證通訊的私密性。

我們此時正應用他與應用層完全不耦合,又經歷20年的發展歷程非常的完善和安全,完全可以信任。

sequenceDiagram Client->>Server: TLS協議版本、隨機數、支援的加密套件和對應公鑰A Server-)Server: 生成隨機數B,根據資訊生成金鑰 Server-->>Client: 選用加密套件,服務端隨機數,服務端證書 Server-->>Client: 使用的P、G、公鑰B與簽名 Server-->>Client: 握手報文的資訊(服務端加密) Client-)Client: 驗證證書,使用a、B計算出K,得到金鑰 Client->>Server: 握手報文的資訊(客戶端加密) Client->>Server: 應用資料(客戶端加密) Server-->>Client: 應用資料(服務端加密)

瞭解RSA演算法

1. 演算法原理

演算法本身基於一個簡單的數論知識:給出兩個素數,很容易將它們相乘,然而給出它們的乘積,想得到這兩個素數就顯得尤為困難。如果能夠解決大整數(比如幾百位的整數)分解的快速方法,那麼 RSA 演算法將輕易被破解。

2.公鑰私鑰的生成
  1. 準備兩個非常大的素數p和q(轉化成二進位制後1024位或者4096或者更大位數,位數越多越難破解);
  2. 計算出兩個大素數的乘積n=pq;
  3. 同樣的方法計算m=(p-1)(q-1),這裡的m為n的尤拉函式
  4. 找到一個數e(1 < e < m),滿足(e,m)的最大公約數為1,即互素
  5. 找到數字d,需滿足ed mod m = 1,即餘數為1
  6. 此時生成完畢,公鑰為(n,e),私鑰為(n, d)
3. RSA加密
對明文x,用公鑰(n, e)對x加密,將x轉換成數字,透過公式得出密文y
y = x^e mod n

4. RSA解密

對明文y,用私鑰(n, d)對y解密
x = y^d mod n

5. 小數測試

取p=5,q=11,得到n=p*q=55
m=(p-1)(q-1) = 40
取e=3,根據ed mod m = 1,可取d=27
此時公鑰(n, e)=(55, 3)
此時私鑰(n, d)=(55, 27)
提供明文a = 14,用公鑰加密則密文c = a ^ e mod n = 14 ^ 3 mod 55 = 49
解密密文b = 49,用私鑰解密則明文d = b ^ d mod n = 49 ^ 27 mod 55 = 14

6. 效能分析

因為RSA用到了指數級的計算,位數又是至少1024位起的,所以計算量非常的龐大,所以RSA的演算法效率並不高,所以TLS除一開始密文交換的時候用到RSA,後續均用得到的密文做對稱加密以減少計算量,TLS1.3所用如下TLS_AES_128_GCM_SHA256TLS_AES_256_GCM_SHA384TLS_CHACHA20_POLY1305_SHA256TLS_AES_128_CCM_SHA256TLS_AES_128_CCM_8_SHA256等對稱加密演算法。

7. Nginx證書檔案pem及key

key檔案,即包含-----BEGIN RSA PRIVATE KEY-----的檔案,這裡麵包含的資訊有n, e, d, p, q等完整的RSA資訊,也是保證安全最重要的資訊,格式類似如下

RSAPrivateKey ::= SEQUENCE {
  version           Version,
  modulus           INTEGER,  -- n
  publicExponent    INTEGER,  -- e
  privateExponent   INTEGER,  -- d
  prime1            INTEGER,  -- p
  prime2            INTEGER,  -- q
  exponent1         INTEGER,  -- d mod (p-1)
  exponent2         INTEGER,  -- d mod (q-1)
  coefficient       INTEGER,  -- (inverse of q) mod p
  otherPrimeInfos   OtherPrimeInfos OPTIONAL
}

pem檔案,包含了公鑰資訊(n, d)及證書鏈資訊,可以知道誰簽發的。

加密節點實現

角色說明,在wmproxy中存在兩種角色,

  1. 末端的處理伺服器
  2. 中間方只進行流量轉發

關於TLS的引數有以下引數

pub struct Proxy {
    /// 連線服務端是否啟用tls
    ts: bool,
    /// 接收客戶端是否啟用tls
    tc: bool,
    /// tls證書所用的域名
    domain: Option<String>,
    /// 公開的證書公鑰檔案
    cert: Option<String>,
    /// 隱私的證書私鑰檔案
    key: Option<String>,
}

因為加密存在可能的效能損耗,若在私有網路裡不存在傳輸安全理論上可以不用開啟加密傳輸。如果存在多個節點,前面節點已啟用過加密,理論上後面節點也無需多次加密。

直接用https傳輸可能暴露什麼?

因為客戶端發起Client Hello的時候必須帶上訪問的domain,也就是網路的嗅探方雖然無法知道你訪問的具體內容,但是可以知道你訪問的網站列表。如:

啟動二級代理
  1. 在本地啟動代理
wmproxy -b 127.0.0.1 -p 8090 -S 127.0.0.1:8091 --ts

因為純轉發,所以在當前節點設定賬號密碼沒有意義-S表示連線到的二級代理地址,有該引數則表示是中轉代理,否則是末端代理。--ts表示連線父級代理的時候需要用加密的方式連結

  1. 在遠端啟動代理
wmproxy --user proxy --pass proxy -b 0.0.0.0 -p 8091 --tc

--tc表示接收子級代理的時候需要用加密的方式連結,可以--cert指定證書的公鑰,--key指定證書的私鑰,--domain指定證書的域名,如果不指定,則預設用自帶的證書引數

至此透過代理訪問的,我們已經沒有辦法得到真正的請求地址,只能得到代理發起的請求

原始碼說明

關於TLS依賴,選擇的是rustlstokio-rustls
那麼關於客戶端的連線,那就有兩種情況,一種是TcpStream,另一種是TlsStream<TcpStream>,我們的處理函式不確定傳入的是哪種型別,所以此前的入參TcpStream全部改成泛型T,類似

async fn deal_stream<T>(&mut self, inbound: T) -> ProxyResult<()>
where T: AsyncRead + AsyncWrite + Unpin {
}

這樣子只要可以非同步讀和寫都可以成為入參的流。

如果存在tc引數,那麼會將客戶端轉成TlsStream以便繼續處理

if let Some(a) = accept.clone() {
    let inbound = a.accept(inbound).await;
    if let Ok(inbound) = inbound {
        // 獲取的流跟正常內容一樣讀寫, 在內部實現了自動加解密
        let _ = self.deal_stream(inbound).await;
    } else {
        println!("accept error = {:?}", inbound.err());
    }
} else {
    let _ = self.deal_stream(inbound).await;
};

客戶端連線

let connector = TlsConnector::from(tls_client.unwrap());
let stream = TcpStream::connect(&server).await?;
// 這裡的域名只為認證設定
let domain = rustls::ServerName::try_from(&*domain.unwrap_or("soft.wm-proxy.com".to_string()))
    .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid dnsname"))?;

if let Ok(mut outbound) = connector.connect(domain, stream).await {
    // connect 之後的流跟正常內容一樣讀寫, 在內部實現了自動加解密
    let _ = tokio::io::copy_bidirectional(&mut inbound, &mut outbound).await?;
}

這裡利用的是TLS與上層解藕,只要他參與握手完之後,完全按我們的通訊來定。

後續改進

現在每個請求都和代理服務端進行一次請求握手,當開啟斷開非常多的時候會比較耗效能,可以考慮共用一條socket然後內部做協議解析,會減少握手時間,只是在流量非常大的時候會出現某條請求耗光了所有的頻寬。