阅读视图

发现新文章,点击刷新页面。
🔲 ☆

使用 acme.sh 签发通配符 SSL 证书

看了很多使用 acme.sh 签发 通配符 SSL 的教程,但讲的比较笼统,所以打算写个详细教程记录一下。

此处以 ZeroSSL 为例,acme.sh也可签发其他证书,可自行研究。

不同品牌证书之间优劣不在此赘述。

2024-08-18更新:增加安装证书以及自动续期。


部署 ACME.SH

下载 acme.sh

1
curl  https://get.acme.sh | sh

img

创建文件链接

1
alias acme.sh=~/.acme.sh/acme.sh

img

切换证书为 ZeroSSL

1
2
3
acme.sh --set-default-ca --server zerossl
#国内服务器推荐 letsencrypt
acme.sh --set-default-ca --server letsencrypt

img

获取zeroSSL eab

打开 https://app.zerossl.com/developer

注册账号并登录,打开页面后点击 Generate

img

绑定邮箱,输入你注册使用的邮箱

1
acme.sh --register-account -m YourEmail

img

获取 EAB KID 和 EAB HMAC Key 并且按格式填写

1
2
3
acme.sh  --register-account  --server zerossl \
--eab-kid EAB KID \
--eab-hmac-key EAB HMAC Key

img

获取DNSPOD API

这里是通过自动 DNS 解析来进行 DNS 验证,也可选择文件验证,但推荐自动验证。

此处以 DNSPOD为例,其他运营商操作类似。

打开 https://console.dnspod.cn/account/token/token 获取 ID/KEY

img

配置DNSPOD API

1
2
export DP_Id="YourID"
export DP_Key="YourKey"

img

自动签发通配符

1
acme.sh --issue --dns dns_dp -d *.domain.com -d domain.com --keylength ec-256

img

img

此时证书签发成功,绿色的部分为证书的存储路径,部署在服务器即可。

fullchain.cer 是证书内容,xxx.key 是证书的私钥。

安装证书及自动续期

在 shell 命令行输入以下命令

1
2
3
4
acme.sh --install-cert -d *.domain.com -d domain.com  --ecc \
--key-file /cert/domain.com/privkey.pem \
--fullchain-file /cert/domain.com/fullchain.pem \
--reloadcmd "service nginx force-reload"

其中具体的目录以及域名需要手动修改。

后语

步骤可能有错误,希望大家斧正。

🔲 ☆

NextCloud 搭配 OnlyOffice出现 已阻止此内容,请与网站所有者联系以解决此问题 的问题

很多人在用 NextCloud 搭配 OnlyOffice 是出现 “已阻止此内容。请与网站所有者联系以解决此问题。”的错误

img出现“已阻止此内容。请与网站所有者联系以解决此问题”

出现这种情况大部分情况下是因为OnlyOffice做了反代,通过nginx配置了 SSL ,而未在 Docker 中配置 SSL 而引发了跨域错误。

从控制台可以看到是发生了跨域,进而证实了猜想

img浏览器控制台查看错误


解决方法:

开启 OnlyOffice 的 SSL 即可解决。

一、在目录中创建 certs 文件夹,将 SSL 的密钥及证书文件重命名为 onlyoffice.key 与 onlyoffice.crt 存入其中。

此处以宝塔面板的Docker管理面板为例

二、配置 OnlyOffice Docker

img

端口映射:需将容器443端口映射至服务器的任意未使用端口即可。

目录映射:需将容器目录 /var/www/onlyoffice/Data 映射至存放 /certs 文件夹 的目录即可(/certs 的父目录,并非映射至 /certs)

将 Nginx 反代地址设置为 https://IP:端口 即可正常使用

imgOnlyOffice 服务恢复正常

🔲 ☆

后 OCSP 时代,浏览器如何应对证书吊销新挑战

2023 年 8 月,CA/Browser Forum 通过了一项投票——不再强制要求 Let’s Encrypt 等公开信任的 CA 设立 OCSP Server

2024 年 7 月,Let's Encrypt 发布博客,披露其计划关闭 OCSP Server

同年 12 月,Let's Encrypt 发布其关闭 OCSP Server 的时间计划表,大致情况如下:

  • 2025 年 1 月 30 日 - Let’s Encrypt 不再接受新的包含 OCSP Must-Staple 扩展的证书签发请求,除非你的账号先前申请过此类证书
  • 2025 年 5 月 7 日 - Let's Encrypt 新签发的证书将加入 CRL URLs,不再包含 OCSP URLs,并且所有新的包含 OCSP Must-Staple 扩展的证书签发请求都将被拒绝
  • 2025 年 8 月 6 日 - Let's Encrypt 关闭 OCSP 服务器

Let's Encrypt 是全世界最大的免费 SSL 证书颁发机构,而这一举动标志着我们已逐渐步入后 OCSP 时代。

OCSP 的困境:性能与隐私的权衡

Let's Encrypt 这一举动的背后,是人们对 OCSP(在线证书状态协议)长久以来累积的不满。OCSP 作为一种实时查询证书有效性的方式,最初的设想很美好:当浏览器访问一个网站时,它可以向 CA(证书颁发机构) 的 OCSP 服务器发送一个简短的请求,询问该证书是否仍然有效。这似乎比下载一个巨大的 CRL(证书吊销列表) 要高效得多。

然而,OCSP 在实际应用中暴露出众多缺陷:

首先是性能问题。尽管单个请求很小,但当数百万用户同时访问网站时,OCSP 服务器需要处理海量的实时查询。这不仅给 CA 带来了巨大的服务器压力,也增加了用户访问网站的延迟。如果 OCSP 服务器响应缓慢甚至宕机,浏览器可能会因为无法确认证书状态而中断连接,或者为了用户体验而不得不“睁一只眼闭一只眼”,这都削弱了 OCSP 的安全性。

更严重的是隐私问题。每一次 OCSP 查询,都相当于向 CA 报告了用户的访问行为。这意味着 CA 能够知道某个用户在何时访问了哪个网站。虽然 OCSP 查询本身不包含个人身份信息,但将这些信息与 IP 地址等数据结合起来,CA 完全可以建立起用户的浏览习惯画像。对于重视隐私的用户和开发者来说,这种“无声的监视”是不可接受的。即使 CA 故意不保留这些信息,地区法律也可能强制 CA 收集这些信息。

再者,OCSP 还存在设计上的安全缺陷。由于担心连接超时影响用户体验,浏览器通常默认采用 soft-fail 机制:一旦无法连接 OCSP 服务器,便会选择放行而非阻断连接。攻击者恰恰可以利用这一点,通过阻断客户端与 OCSP 服务器之间的通信,使查询始终超时,从而轻松绕过证书状态验证。

OCSP 装订 (OCSP stapling)

基于上面这些缺陷,我们有了 OCSP 装订 (OCSP stapling) 方案,这在我去年的博客里讲过,欢迎回顾

强制 OCSP 装订 (OCSP Must-Staple)

OCSP Must-Staple 是一个在 ssl 证书申请时的拓展项,该扩展会告知浏览器:若在证书中识别到此扩展,则不得向证书颁发机构发送查询请求,而应在握手阶段获取装订式副本。若未能获得有效副本,浏览器应拒绝连接。

这项功能赋予了浏览器开发者 hard-fail 的勇气,但在 OCSP 淡出历史之前,Let's Encrypt 似乎是唯一支持这一拓展的主流 CA,并且这项功能并没有得到广泛使用。

~~本来不想介绍这项功能的(因为根本没人用),但考虑到这东西快入土了,还是给它在中文互联网中立个碑,~~更多信息参考 Let's Encrypt 的博客

Chromium 的方案:弱水三千只取一瓢

OCSP 的隐私和性能问题并非秘密,浏览器厂商们早就开始了各自的探索。2012 年,Chrome 默认禁用了 CRLs、OCSP 检查,转向自行设计的证书校验机制。

众所周知,吊销列表可以非常庞大。如果浏览器需要下载和解析一个完整的全球吊销列表,那将是一场性能灾难(Mozilla 团队在今年的博客中提到,从 3000 个活跃的 CRL 下载的文件大小将达到 300MB)。Chromium 团队通过分析历史数据发现,大多数被吊销的证书属于少数高风险类别,例如证书颁发机构(CA)本身被攻破、或者某些大型网站的证书被吊销。基于此洞察,CRLSets 采取了以下策略:

  1. 分层吊销:Chromium 不会下载所有被吊销的证书信息,而是由 Google 团队维护一个精简的、包含“最重要”吊销信息的列表。这个列表会定期更新并通过 Chrome 浏览器更新推送给用户。
  2. 精简高效:这个列表体积非常小,目前大概只有 600KB。它包含了那些一旦被滥用就会造成大规模安全事故的证书,例如 CA 的中间证书、或者一些知名网站(如 Google、Facebook)的证书。
  3. 牺牲部分安全性:这种方案的缺点也很明显——它无法覆盖所有的证书吊销情况。对于一个普通网站的证书被吊销,CRLSets 大概率无法检测到。根据 Mozilla 今年的博客所说,CRLSets 只包含了 1%~2% 的未过期的被吊销证书信息。

虽然 CRLSets 是一种“不完美”的解决方案,但它在性能和可用性之间找到了一个平衡点。它确保了用户在访问主流网站时的基础安全,同时避免了 OCSP 带来的性能和隐私开销。对于 Chromium 而言,与其追求一个在现实中难以完美实现的 OCSP 方案,不如集中精力解决最紧迫的安全威胁。

Firefox 的方案:从 CRLs 到 CRLite

与 Chromium 的“只取一瓢”策略不同,Firefox 的开发者们一直在寻找一种既能保证全面性,又能解决性能问题的方案。

为了解决这个问题,Mozilla 提出了一个创新的方案:CRLite。CRLite 的设计理念是通过哈希函数和布隆过滤器等数据结构,将庞大的证书吊销列表压缩成一个小巧、可下载且易于本地验证的格式

CRLite 的工作原理可以简单概括为:

  1. 数据压缩:CA 定期生成其全部吊销证书的列表。
  2. 服务器处理:Mozilla 的服务器会收集这些列表,并使用加密哈希函数和布隆过滤器等技术,将所有吊销证书的信息编码成一个非常紧凑的数据结构。
  3. 客户端验证:浏览器下载这个压缩文件,当访问网站时,只需本地对证书进行哈希计算,然后查询这个本地文件,就能快速判断该证书是否已被吊销。

与 CRLSets 相比,CRLite 的优势在于它能够实现对所有吊销证书的全面覆盖,同时保持极小的体积。更重要的是,它完全在本地完成验证,这意味着浏览器无需向任何第三方服务器发送请求,从而彻底解决了 OCSP 的隐私问题。

Firefox 当前的策略为每 12 小时对 CRLite 数据进行一次增量更新,每日的下载数据大约为 300KB;每 45 天进行一次全量的快照同步,下载数据约为 4MB。

Mozilla 开放了他们的数据看板,你可以在这里找到近期的 CRLite 数据大小:https://yardstick.mozilla.org/dashboard/snapshot/c1WZrxGkNxdm9oZp7xVvGUEFJCELfApN

自 2025 年 4 月 1 日发布的 Firefox Desktop 137 版本起,Firefox 开始逐步以 CRLite 替换 OCSP 校验;同年 8 月 19 日,Firefox Desktop 142 针对 DV 证书正式弃用 OCSP 检验。

CRLite 已经成为 Firefox 未来证书吊销验证的核心方案,它代表了对性能、隐私和安全性的全面追求。

后 OCSP 时代的展望

随着 Let's Encrypt 等主要 CA 关闭 OCSP 服务,OCSP 的时代正在加速落幕。我们可以看到,浏览器厂商们已经开始各自探索更高效、更安全的替代方案。

  • Chromium 凭借其 CRLSets 方案,在性能和关键安全保障之间取得了务实的平衡。
  • Firefox 则通过 CRLite 这一技术创新,试图在全面性、隐私和性能三者之间找到最佳的解决方案。

这些方案的共同点是:将证书吊销验证从实时在线查询(OCSP)转变为本地化验证,从而规避了 OCSP 固有的性能瓶颈和隐私风险。

未来,证书吊销的生态系统将不再依赖单一的、中心化的 OCSP 服务器。取而代之的是,一个更加多元、分布式和智能化的新时代正在到来。OCSP 这一技术可能逐渐被淘汰,但它所试图解决的“证书吊销”这一核心安全问题,将永远是浏览器和网络安全社区关注的重点。

参见

🔲 ⭐

南墙 WAF 系列(二)– 网站证书自动更新

相对管理后台的 ssl来说,其实网站的 ssl 证书才是正事,毕竟这个关系到网站的访问。按照官方的说法在开放 80 端口的情况下,南墙可以自动申请更新证书,不过后台没找到配置的地方,我的 v4 的 80 也是不通的,所以就需要自己去维护管理证书了。

然而,上午在问了管理之后,得到的答复是没有 api,可以自己抓包进行修改。

嗐,这么看来其实也没啥,最起码说明后台的 api 接口是可以直接拿来用的。即使是有 api 文档,也是得自己去看,去写,没有的话 curl 抓包一样能解决问题。按照之前的方法,只直接复制 curl 给 cursor 就可以了。

api 文件baby_nanqiang_api_tools.py内容:

#!/usr/bin/env python3
import requests
import json
import jwt
from datetime import datetime
import os
import urllib3

# 禁用 SSL 验证警告
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

class NanQiangAPI:
    def __init__(self, base_url="https://lang.bi:443"):
        self.base_url = base_url
        self.session = requests.Session()
        self.session.verify = False  # 忽略SSL证书验证
        self.token = None
        self._setup_headers()

    def _setup_headers(self):
        """设置请求头"""
        self.headers = {
            'accept': 'application/json, text/plain, */*',
            'accept-language': 'zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7',
            'cache-control': 'no-cache',
            'content-type': 'application/json',
            'origin': self.base_url,
            'pragma': 'no-cache',
            'priority': 'u=1, i',
            'referer': f'{self.base_url}/',
            'sec-ch-ua': '"Chromium";v="134", "Not:A-Brand";v="24", "Google Chrome";v="134"',
            'sec-ch-ua-mobile': '?0',
            'sec-ch-ua-platform': '"macOS"',
            'sec-fetch-dest': 'empty',
            'sec-fetch-mode': 'cors',
            'sec-fetch-site': 'same-origin',
            'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36'
        }

    def _update_headers_with_token(self):
        """更新请求头,添加token"""
        if self.token:
            self.headers['Authorization'] = self.token  # 直接使用token,不添加'Bearer '前缀

    def delete_cert(self, cert_id):
        """
        删除指定ID的证书
        :param cert_id: 证书ID
        :return: 删除结果
        """
        if not self.is_logged_in():
            print("请先登录")
            return None

        url = f"{self.base_url}/api/v1/certs/{cert_id}"

        try:
            response = self.session.delete(
                url,
                headers=self.headers
            )
            
            response_data = response.json()
            
            if 'err' in response_data:
                print(f"删除证书失败: {response_data['err']}")
                return None
            
            # 检查删除是否成功
            if response_data.get('result') == 'success' and response_data.get('RowsAffected') > 0:
                print(f"证书 {cert_id} 删除成功")
                return True
            else:
                print(f"证书 {cert_id} 删除失败: 未找到证书或删除操作未生效")
                return False
            
        except requests.exceptions.RequestException as e:
            print(f"删除证书请求失败: {str(e)}")
            return None
        except json.JSONDecodeError as e:
            print(f"解析响应数据失败: {str(e)}")
            return None

    def parse_cert_list(self, cert_list):
        """
        解析证书列表数据
        :param cert_list: 证书列表数据
        :return: 解析后的证书信息列表
        """
        if not cert_list:
            return None

        parsed_certs = []
        for cert in cert_list:
            try:
                # 解析SNI字段(JSON字符串)
                sni_list = json.loads(cert.get('sni', '[]'))
                
                parsed_cert = {
                    'id': cert.get('id'),
                    'sni': sni_list,
                    'expire_time': cert.get('expire_time'),
                    'update_time': cert.get('update_time')
                }
                parsed_certs.append(parsed_cert)
            except json.JSONDecodeError as e:
                print(f"解析SNI字段失败: {str(e)}")
                continue
            except Exception as e:
                print(f"解析证书数据失败: {str(e)}")
                continue

        return parsed_certs

    def get_cert_list(self):
        """
        获取证书列表
        :return: 证书列表
        """
        if not self.is_logged_in():
            print("请先登录")
            return None

        url = f"{self.base_url}/api/v1/certs/"

        try:
            response = self.session.get(
                url,
                headers=self.headers
            )
            
            response_data = response.json()
            
            if 'err' in response_data:
                print(f"获取证书列表失败: {response_data['err']}")
                return None
                
            return response_data
            
        except requests.exceptions.RequestException as e:
            print(f"获取证书列表请求失败: {str(e)}")
            return None
        except json.JSONDecodeError as e:
            print(f"解析响应数据失败: {str(e)}")
            return None

    def login(self, username, password, otp=""):
        """
        登录接口
        :param username: 用户名
        :param password: 密码
        :param otp: 双因素认证码(可选)
        :return: 登录响应
        """
        url = f"{self.base_url}/api/v1/users/login"
        data = {
            "usr": username,
            "pwd": password,
            "otp": otp
        }

        try:
            response = self.session.post(
                url,
                headers=self.headers,
                json=data
            )
            
            # 获取响应数据
            response_data = response.json()
            
            # 检查是否有错误信息
            if 'err' in response_data:
                print(f"登录失败: {response_data['err']}")
                return None
            
            # 保存token
            if 'token' in response_data:
                self.token = response_data['token']
                self._update_headers_with_token()
                
                # # 解析token信息
                # try:
                #     # 使用 jwt.decode 替代 jwt.decode_complete
                #     token_data = jwt.decode(self.token, options={"verify_signature": False})
                #     exp_timestamp = token_data.get('exp')
                #     if exp_timestamp:
                #         exp_date = datetime.fromtimestamp(exp_timestamp)
                #         print(f"Token 有效期至: {exp_date}")
                # except Exception as e:
                #     print(f"无法解析token信息: {str(e)}")
                
            return response_data
            
        except requests.exceptions.RequestException as e:
            print(f"登录请求失败: {str(e)}")
            return None
        except json.JSONDecodeError as e:
            print(f"解析响应数据失败: {str(e)}")
            return None

    def check_cert(self, cert_content, key_content, mode=0):
        """
        检查证书
        :param cert_content: 证书内容
        :param key_content: 私钥内容
        :param mode: 模式,默认为0
        :return: 检查结果
        """
        if not self.is_logged_in():
            print("请先登录")
            return None

        url = f"{self.base_url}/api/v1/certs/check"
        
        # 准备multipart/form-data数据
        files = {
            'mode': (None, str(mode)),
            'cert': (None, cert_content),
            'key': (None, key_content)
        }

        try:
            # 临时移除content-type,让requests自动设置
            headers = self.headers.copy()
            headers.pop('content-type', None)
            
            response = self.session.post(
                url,
                headers=headers,
                files=files
            )
            
            response_data = response.json()
            
            if 'err' in response_data:
                print(f"证书检查失败: {response_data['err']}")
                return None
                
            return response_data
            
        except requests.exceptions.RequestException as e:
            print(f"证书检查请求失败: {str(e)}")
            return None
        except json.JSONDecodeError as e:
            print(f"解析响应数据失败: {str(e)}")
            return None

    def check_cert_from_files(self, cert_file_path, key_file_path, mode=0):
        """
        从文件检查证书
        :param cert_file_path: 证书文件路径
        :param key_file_path: 私钥文件路径
        :param mode: 模式,默认为0
        :return: 检查结果
        """
        try:
            with open(cert_file_path, 'r') as f:
                cert_content = f.read()
            with open(key_file_path, 'r') as f:
                key_content = f.read()
                
            return self.check_cert(cert_content, key_content, mode)
            
        except FileNotFoundError as e:
            print(f"文件不存在: {str(e)}")
            return None
        except Exception as e:
            print(f"读取文件失败: {str(e)}")
            return None

    def submit_cert_config(self, check_result):
        """
        提交证书配置
        :param check_result: 证书检查的结果数据
        :return: 提交结果
        """
        if not self.is_logged_in():
            print("请先登录")
            return None

        if not check_result:
            print("无效的证书检查结果")
            return None

        url = f"{self.base_url}/api/v1/certs/config"
        
        # 准备提交数据
        data = {
            "id": check_result.get("id", 0),
            "sni": check_result.get("sni", "[]"),
            "cert": check_result.get("cert", ""),
            "key": check_result.get("key", ""),
            "expire_time": check_result.get("expire_time", ""),
            "update_time": check_result.get("update_time", "")
        }

        try:
            response = self.session.post(
                url,
                headers=self.headers,
                json=data
            )
            
            response_data = response.json()
            
            if 'err' in response_data:
                print(f"证书配置提交失败: {response_data['err']}")
                return None
                
            return response_data
            
        except requests.exceptions.RequestException as e:
            print(f"证书配置提交请求失败: {str(e)}")
            return None
        except json.JSONDecodeError as e:
            print(f"解析响应数据失败: {str(e)}")
            return None

    def is_logged_in(self):
        """
        检查是否已登录
        :return: bool
        """
        return self.token is not None

def main():
    # 使用示例
    api = NanQiangAPI()
    
    # 登录信息
    username = "obaby"
    password = "obaby@mars"
    
    # 执行登录
    result = api.login(username, password)
    
    if result:
        print("登录成功:")
        print(json.dumps(result, indent=2, ensure_ascii=False))
        print(f"Token: {api.token}")
        
        # 获取证书列表
        cert_list = api.get_cert_list()
        if cert_list:
            # 解析证书列表
            parsed_certs = api.parse_cert_list(cert_list)
            if parsed_certs:
                print("解析后的证书列表:")
                print(json.dumps(parsed_certs, indent=2, ensure_ascii=False))
                
                # # 删除证书示例
                # cert_id = 4  # 要删除的证书ID
                # delete_result = api.delete_cert(cert_id)
                # if delete_result:
                #     print(f"证书 {cert_id} 删除成功")
                # else:
                #     print(f"证书 {cert_id} 删除失败")
        
        # 证书检查示例
        cert_file = "path/to/cert.pem"
        key_file = "path/to/key.pem"
        
        if os.path.exists(cert_file) and os.path.exists(key_file):
            return
            # 先检查证书
            cert_result = api.check_cert_from_files(cert_file, key_file)
            if cert_result:
                print("证书检查结果:")
                print(json.dumps(cert_result, indent=2, ensure_ascii=False))
                
                # 提交证书配置
                submit_result = api.submit_cert_config(cert_result)
                if submit_result:
                    print("证书配置提交成功:")
                    print(json.dumps(submit_result, indent=2, ensure_ascii=False))
                else:
                    print("证书配置提交失败")
    else:
        print("登录失败")

if __name__ == "__main__":
    main()

账号不要设置动态密码,如果设置了,那就创建一个新账号。

获取证书的脚本参考上一篇文章,对应的路径自己调整。更新证书的代码site_cert_auto_update_tool.py:

#!/usr/bin/env python3
import os
import subprocess
import hashlib
import json
from datetime import datetime
import logging
from baby_nanqiang_api_tools import NanQiangAPI

# Configuration
CERT_SOURCE_DIR = "/root/.acme.sh/h4ck.org.cn_ecc"
CERT_FILE = "fullchain.cer"
KEY_FILE = "h4ck.org.cn.key"
HASH_FILE = "web_cert_hash.json"
CERT_SCRIPT = "get_web_cert.sh"

def setup_logging():
    """设置日志"""
    logging.basicConfig(
        level=logging.INFO,
        format='%(asctime)s - %(levelname)s - %(message)s',
        handlers=[
            logging.FileHandler('web_cert_update.log'),
            logging.StreamHandler()
        ]
    )

def get_file_hash(file_path):
    """计算文件的SHA-256哈希值"""
    sha256_hash = hashlib.sha256()
    with open(file_path, "rb") as f:
        for byte_block in iter(lambda: f.read(4096), b""):
            sha256_hash.update(byte_block)
    return sha256_hash.hexdigest()

def save_cert_hash(cert_hash, key_hash):
    """保存证书和私钥的哈希值到JSON文件"""
    with open(HASH_FILE, 'w') as f:
        json.dump({
            'cert_hash': cert_hash,
            'key_hash': key_hash
        }, f)

def load_cert_hash():
    """从JSON文件加载证书和私钥的哈希值"""
    try:
        with open(HASH_FILE, 'r') as f:
            data = json.load(f)
            return data.get('cert_hash'), data.get('key_hash')
    except (FileNotFoundError, json.JSONDecodeError):
        return None, None

def run_get_cert_script(script_path=None):
    """
    执行获取证书的脚本
    :param script_path: 脚本路径,如果为None则使用默认的get_web_cert.sh
    :return: bool 是否执行成功
    """
    try:
        # 如果没有指定脚本路径,使用默认的get_web_cert.sh
        if script_path is None:
            script_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), CERT_SCRIPT)
        
        # 检查脚本是否存在
        if not os.path.exists(script_path):
            logging.error(f"错误: 脚本文件 {script_path} 不存在")
            return False
            
        # 检查脚本是否可执行
        if not os.access(script_path, os.X_OK):
            logging.error(f"错误: 脚本文件 {script_path} 没有执行权限")
            return False
            
        # 执行脚本
        result = subprocess.run(['sh', script_path], 
                              capture_output=True, 
                              text=True)
        
        # 检查执行结果
        if result.returncode == 0:
            logging.info("证书获取脚本执行成功")
            if result.stdout:
                logging.info("脚本输出:\n%s", result.stdout)
            return True
        else:
            logging.error(f"证书获取脚本执行异常,返回码: {result.returncode}")
            if result.stderr:
                logging.error("异常输出:\n%s", result.stderr)
            return True
            
    except Exception as e:
        logging.error(f"执行证书获取脚本时发生错误: {str(e)}")
        return False

def read_file_content(file_path):
    """读取文件内容"""
    try:
        with open(file_path, 'r') as f:
            return f.read()
    except Exception as e:
        logging.error(f"读取文件 {file_path} 失败: {str(e)}")
        return None

def is_cert_expired(expire_time_str):
    """
    检查证书是否过期或即将过期(7天内)
    :param expire_time_str: 过期时间字符串
    :return: bool 是否过期或即将过期
    """
    try:
        expire_time = datetime.strptime(expire_time_str, "%Y-%m-%d %H:%M:%S")
        now = datetime.now()
        days_until_expire = (expire_time - now).days
        return days_until_expire <= 7
    except Exception as e:
        logging.error(f"解析过期时间失败: {str(e)}")
        return False

def process_same_sni_certs(api, parsed_certs, current_sni, current_cert_id):
    """
    处理具有相同SNI的证书
    :param api: API实例
    :param parsed_certs: 解析后的证书列表
    :param current_sni: 当前证书的SNI
    :param current_cert_id: 当前证书的ID
    :return: None
    """
    # 筛选出相同SNI的证书
    same_sni_certs = [cert for cert in parsed_certs 
                     if cert['sni'] == current_sni and cert['id'] != current_cert_id]
    
    if not same_sni_certs:
        return
        
    # 按过期时间排序(从早到晚)
    same_sni_certs.sort(key=lambda x: datetime.strptime(x['expire_time'], "%Y-%m-%d %H:%M:%S"))
    
    # 检查是否有过期或即将过期的证书
    for cert in same_sni_certs:
        if is_cert_expired(cert['expire_time']):
            logging.info(f"删除过期证书 ID: {cert['id']}")
            if not api.delete_cert(cert['id']):
                logging.error(f"删除证书 {cert['id']} 失败")
    
    # 检查是否有过期时间相同的证书
    if len(same_sni_certs) > 1:
        # 获取第一个证书的过期时间作为基准
        base_expire_time = same_sni_certs[0]['expire_time']
        
        # 删除过期时间相同的证书(保留第一个)
        for cert in same_sni_certs[1:]:
            if cert['expire_time'] == base_expire_time:
                logging.info(f"删除重复过期时间的证书 ID: {cert['id']}")
                if not api.delete_cert(cert['id']):
                    logging.error(f"删除证书 {cert['id']} 失败")

def main():
    # 设置日志
    setup_logging()
    
    try:
        # 执行证书获取脚本
        if not run_get_cert_script():
            logging.error("获取证书失败,退出程序")
            return
            
        # 检查证书文件是否存在
        cert_path = os.path.join(CERT_SOURCE_DIR, CERT_FILE)
        key_path = os.path.join(CERT_SOURCE_DIR, KEY_FILE)
        
        if not (os.path.exists(cert_path) and os.path.exists(key_path)):
            logging.error("证书文件不存在,退出程序")
            return
            
        # 计算新文件的哈希值
        new_cert_hash = get_file_hash(cert_path)
        new_key_hash = get_file_hash(key_path)
        
        # 获取旧的哈希值
        old_cert_hash, old_key_hash = load_cert_hash()
        
        # 检查文件是否发生变化
        if new_cert_hash != old_cert_hash or new_key_hash != old_key_hash:
            logging.info("证书文件已发生变化,开始更新流程")
            
            # 读取证书和私钥内容
            cert_content = read_file_content(cert_path)
            key_content = read_file_content(key_path)
            
            if not cert_content or not key_content:
                logging.error("读取证书文件失败")
                return
                
            # 初始化API
            api = NanQiangAPI()
            
            # 登录
            if not api.login("obaby", "obaby@mars"):
                logging.error("登录失败")
                return
                
            # 检查证书
            check_result = api.check_cert(cert_content, key_content)
            if not check_result:
                logging.error("证书检查失败")
                return
                
            # 提交证书配置
            if not api.submit_cert_config(check_result):
                logging.error("提交证书配置失败")
                return
                
            # 获取证书列表
            cert_list = api.get_cert_list()
            if not cert_list:
                logging.error("获取证书列表失败")
                return
                
            # 解析证书列表
            parsed_certs = api.parse_cert_list(cert_list)
            if not parsed_certs:
                logging.error("解析证书列表失败")
                return
                
            # 获取当前证书的SNI
            current_sni = check_result.get('sni', '[]')
            try:
                current_sni = json.loads(current_sni)
            except json.JSONDecodeError:
                logging.error("解析当前证书SNI失败")
                return
                
            # 处理相同SNI的证书
            process_same_sni_certs(api, parsed_certs, current_sni, check_result.get('id'))
            
            # 保存新的哈希值
            save_cert_hash(new_cert_hash, new_key_hash)
            logging.info("证书更新完成")
        else:
            logging.info("证书文件未发生变化,无需更新")
            
    except Exception as e:
        logging.error(f"程序执行出错: {str(e)}", exc_info=True)

if __name__ == "__main__":
    main()

添加定时任务,每天,或者每几天:

0 2 * * * /usr/bin/python3 /home/soft/baby-nanqiang-cert-tools/site_cert_auto_update_tool.py >> /home/soft/baby-nanqiang-cert-tools/web_cert_manager.log 2>&1

最终效果:

The post 南墙 WAF 系列(二)– 网站证书自动更新 appeared first on obaby@mars.

🔲 ☆

安装 Ubuntu 24.04 (LTS), Webmin, Nginx, MariaDB, PHP8.3-FPM,Perl-Fastcgi 到 DigitalOcean 的 VPS(4)

安装 Ubuntu 24.04 LTS, Webmin, Nginx, MariaDB, PHP8.1-FPM,Perl-Fastcgi 到 DigitalOcean 的 VPS上。

DavidYin 介绍了如何在 DigitalOcean 创建新 VPS。并且完成基本的 Ubuntu 24.04 LTS的系统。然后介绍如何安装 Webmin 主机控制面板,时区设置和 SSH 的安全设置。再之后说明一下如何用之前的新添加的用户来安装 Nginx Web 服务器和 MairaDB 数据库服务器。

现在就是很重要的语言支持部分了。因为我用的最多的就是 php 以及 perl 语言。所以这两部分就是重点了。

第四部分
安装 php8.3-fpm

Ubuntu 24.04 LTS 仓库所包含的是 php8.3.6,目前 php 官方支持的版本是 8.1,8.2,8.3 这三个系列。所以直接使用 Ubuntu 的就已经是很新的版本了。

sudo apt install php8.3 php8.3-fpm php8.3-cli php8.3-common php8.3-mbstring php8.3-gd php8.3-intl php8.3-xml php8.3-mysql php8.3-zip php8.3-curl

安装完成后,执行 php -v 命令,可以看到版本信息。

davidyin@walnut:~$ php -v
PHP 8.3.6 (cli) (built: Sep 30 2024 15:17:17) (NTS)
Copyright (c) The PHP Group
Zend Engine v4.3.6, Copyright (c) Zend Technologies
    with Zend OPcache v8.3.6, Copyright (c), by Zend Technologies
davidyin@walnut:~$

再看一下 php8.3-fpm 是否已经运行。

walnut-php-fpm.jpg

设置虚拟主机

说明:下面这些都是用来举例说明所用的,在实际使用中请用真实的数据。
IP: 143.110.227.68
Domain: u24.webexample.win
username: davidyin

接下来我要设置一个 vhost,就是一个虚拟主机,我用的域名是 u24.webexample.win,此为举例而已。 到域名服务商的网站,专门设置域名记录的地方,把 u24.webexample.win 的 A 记录指向此 VPS 的 IP 地址,生效可能需要十分钟或更久,为快捷,可以在所操作的Windows hosts 文件添加纪录使之在本地立即可用。 新建一个主机配置文件,u24.conf,如下。

sudo nano /etc/nginx/conf.d/u24.conf

这里我会定义 log 文件的格式,以及它的储存位置。

log_format   main '$remote_addr - $remote_user [$time_local]  $status '
    '"$request" $body_bytes_sent "$http_referer" '
    '"$http_user_agent" "$http_x_forwarded_for"';

server {
    listen       80;
    server_name  u24.webexample.win;
    access_log  /var/log/nginx/host.access.log  main;

    root   /home/davidyin/u24.webexample.win;
    index  index.php index.html index.htm;
    
    location / {
        try_files $uri $uri/ = 404;
    }

    # redirect server error pages to the static page /50x.html
    #
    error_page   500 502 503 504  /50x.html;
    location = /50x.html {
        root   /usr/share/nginx/html;
    }

    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
      fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;     } # deny access to .htaccess files, if Apache's document root # concurs with nginx's one # location ~ /\.ht { deny all; } }

保存退出,然后执行 sudo nginx -t 命令看看,是否配置文件正确。若正确,就重启 Nginx 服务,使配置生效。

sudo service nginx restart

在/home/davidyin/u24.webexample.win/下新建一个文件 info.php

输入如下内容:

<?php
phpinfo();

回到桌面浏览器中,输入网址 http://u24.webexample.win/info.php,如果看到下面这些内容,就表示 php 安装正确, nginx 也运行正确。

我一般会把 www-data 用户添加到 当前用户的组内,比如我这里用的 davidyin 用户就在同名的 davidyin 组内。

有时候会出现奇怪的问题,找不到文件啊,没有权限啊。这是最好的解决方法,就是重启服务器。有时候是配置未生效。

walnut-phpinfo.jpg


如果出现问题,或者是页面错误,可以查看这两个日志文件。

/var/log/nginx/host.access.log
/var/log/nginx/error.log

至此,php8.3 已经安装完毕,并且虚拟主机也可以使用 php 的语言了。

SSL 证书的签发

如果是商用,或者愿意购买一年期的证书,DavidYin建议到 Gogetssl 购买,这里价格最优,无限重签,不限服务器。目前的证书可以购买5年的,但是实际签发的证书都是一年的,每年重新签发一次,直到购买的年份用完为止。买多年的会便宜一点。

  • Sectigo Essential SSL 一年的证书,$15.84;两年证书,$27.72;五年证书 $63.36。
  • Sectigo PositiveSSL 一年的证书,$7.70;两年证书,$13.48;五年证书 $30.80。
  • GoGetSSL Domain SSL 一年的证书,$4.50;两年证书,$7.88;五年证书 $18.00。
  • 还有 Thawte, DigiCert,GeoTrust,RepidSSL 的证书可以选择。
  • 目前我用下来还是 GoGetSSL 自己的 DV 证书最便宜。

Gogetssl 证书的好处是你买了一个证书给域名 A 用,如果这个域名不用了,还可以签发给域名 B 使用剩下的时间。

免费证书这里我采用 Zerossl 的 SSL 证书。使用了 Neilpang 的 ACME.SH 来安装。每月自动续签。

先安装工具。

curl https://get.acme.sh | sh -s email=seo@g2soft.net

接下来,重新登入SSH,相当于重新载入 BASH 环境,因为上面的安装已经把路径配置到 Bash 中了,并且自动创建了一个 bash 的别名,方便使用,直接输入 acme.sh 命令就可以了。另外还自动创建了一个 cronjob,每天零点自动检测所有的证书,如果快过期,就会自动更新。

验证域名的方式有两种,DNS 和 http,这次我用了 http 方式来验证。

davidyin@walnut:~$ acme.sh --issue -d u24.webexample.win --webroot /home/davidyin/u24.webexample.win
[Mon Oct  7 16:37:46 PDT 2024] Using CA: https://acme.zerossl.com/v2/DV90
[Mon Oct  7 16:37:46 PDT 2024] Single domain='u24.webexample.win'
[Mon Oct  7 16:37:48 PDT 2024] Getting webroot for domain='u24.webexample.win'
[Mon Oct  7 16:37:48 PDT 2024] Verifying: u24.webexample.win
[Mon Oct  7 16:37:49 PDT 2024] Processing. The CA is processing your order, please wait. (1/30)
[Mon Oct  7 16:37:52 PDT 2024] Success
[Mon Oct  7 16:37:52 PDT 2024] Verification finished, beginning signing.
[Mon Oct  7 16:37:52 PDT 2024] Let's finalize the order.
[Mon Oct  7 16:37:52 PDT 2024] Le_OrderFinalize='https://acme.zerossl.com/v2/DV90/order/-0HtI52SzVp9B1iWfXvHrw/finalize'
[Mon Oct  7 16:37:53 PDT 2024] Order status is 'processing', let's sleep and retry.
[Mon Oct  7 16:37:53 PDT 2024] Sleeping for 15 seconds then retrying
[Mon Oct  7 16:38:09 PDT 2024] Polling order status: https://acme.zerossl.com/v2/DV90/order/-0HtI52SzVp9B1iWfXvHrw
[Mon Oct  7 16:38:09 PDT 2024] Downloading cert.
[Mon Oct  7 16:38:09 PDT 2024] Le_LinkCert='https://acme.zerossl.com/v2/DV90/cert/65Q_RSKwu-urE1DZVXE7FA'
[Mon Oct  7 16:38:10 PDT 2024] Cert success.
-----BEGIN CERTIFICATE-----
MIIEBzCCA4ygAwIBAgIRANWHTHkkfhcpadmh96AqH5IwCgYIKoZIzj0EAwMwSzEL
MAkGA1UEBhMCQVQxEDAOBgNVBAoTB1plcm9TU0wxKjAoBgNVBAMTIVplcm9TU0wg
中间省略
2u8271N/ejTHa2yuKuF4KiMP+BywmEifAjEAm/U9GoOqf7u/4yiVAAp6Neo5Nt5Q
Xm/X1Y3+KB0c636aAkftFce8fXep9o5RXpB2
-----END CERTIFICATE-----
[Mon Oct  7 16:38:10 PDT 2024] Your cert is in: /home/davidyin/.acme.sh/u24.webexample.win_ecc/u24.webexample.win.cer
[Mon Oct  7 16:38:10 PDT 2024] Your cert key is in: /home/davidyin/.acme.sh/u24.webexample.win_ecc/u24.webexample.win.key
[Mon Oct  7 16:38:10 PDT 2024] The intermediate CA cert is in: /home/davidyin/.acme.sh/u24.webexample.win_ecc/ca.cer
[Mon Oct  7 16:38:10 PDT 2024] And the full-chain cert is in: /home/davidyin/.acme.sh/u24.webexample.win_ecc/fullchain.cer

验证正确,就会自动签发证书,证书会临时先存放在一个工作目录,现在我要指定一个目录存放: /home/davidyin/ssl/。 之后就是安装证书到该目录。

acme.sh --install-cert -d u24.webexample.win \
--key-file       /home/davidyin/ssl/key  \
--fullchain-file /home/davidyin/ssl/cert \
--reloadcmd     "service nginx force-reload" 

就这样,证书也签发好了,也安装到指定位置,接下来会介绍如何在 nginx 的配置文件中,设置证书路径,设置 https,设置重定向,还有 perl-fastcgi等等。

🔲 ⭐

自建图床小记三—— SSL 证书的自动更新与部署

为什么要自动更新?

众所周知,为站点开启 https 访问需要获得对应 host 的 ssl 证书,而如果希望证书被访客的浏览器所信任,需要拿到由 Certificate Authority (CA) 签发的 ssl 证书。在前一阵子那波 BAT 等大厂提供的云服务停止发放免费的由 TrustAsia/DigiCert 签发的一年有效期免费 ssl 证书之后,市面上已经没有被广泛信任的 CA 签发的免费的一年有效期的 ssl 证书了,于是不得不用回由 Let's Encrypt/ZeroSSL 等 CA 签发三个月免费证书。

但话又说回来,三个月有效期确实不太够,一年有效期的证书就一年一更,手动申请部署也不麻烦;三个月有效期的证书手动就有点麻烦了——我一般会在证书到期的前 15 天进行更新,防止最后几天自己太忙了没时间管。

这套图床架构的自动更新有没有困难?

境外

通过 Cloudflare SaaS 接入的域名通过验证后会自动获得由 Cloudflare 提供的由 Google Trust Services 签发的证书,不需要我们操心。

SSL Certificate provided by Cloudflare

境内

咱选用的又拍云 CDN 提供了免费的 Let's Encrypt 证书及其自动续期服务,但需要我们把图床访问域名的 DNS CNAME 解析到他们家。

SSL Certificate provided by upyun

这里有个问题,我们这套图床架构在境外的解析是解析到 Cloudflare 的,不可能通过 Let's Encrypt 的 acme challenge。如果使用 upyun 申请 ssl 证书,则意味着每次更新都要我们手动将境外的 dns 解析记录暂时解析到又拍云,待证书更新成功后再解析回 Cloudflare,非常麻烦。

使用 Github Action 跑 acme.sh 获取 ssl 证书

本着「能使用长期免费稳定服务就使用长期免费稳定服务」的思想,决定使用 Github Action 申请 ssl 证书。

在 Github Action 跑 acme.sh 获取 ssl 证书意味着不能使用 http 文件检验的方式检验域名所有权,需要使用 dns 检验。截至本文写作时间,acme.sh 已经支持了 150+ 个主流的 DNS 解析商(Managed DNS providers)的 api,针对不支持 api 修改 dns 解析记录的,还可以使用 DNS alias 模式——即将需要申请 ssl 证书的域名先 cname 到一个工具人域名上,将工具人域名通过 NS 解析到 acme.sh 支持的 DNS 解析商,进而实现 CA 对域名所有权的验证。

先在本地跑起来

我采用的是 Cloudflare,直接在个人资料页创建一个具有编辑 DNS 权限的 API 令牌

创建令牌

获得令牌

随后在自己的域名页面,找到区域 ID 和 账户 ID

区域 ID 和 账户 ID

在自己的本机安装 acme.sh,设置好 Cloudflare DNS 的几个变量

export CF_Token=""
export CF_Account_ID=""
export CF_Zone_ID=""

随后可以尝试使用 acme.sh 签发 ssl 证书

acme.sh --issue --dns dns_cf -d cdn.example.com

ssl 证书到手

上 Github Action

原本是打算直接用 Menci/acme 这个 Action的,可惜遇到了点问题。

在我本地,Cloudflare 相关的 Token 和 ID 并没有被写入到 account.conf,而是被写在 cdn.example.com_ecc/cdn.exampe.com.conf,大概就没办法直接用这个 Action 了,不得不转去手搓。不过好在 Menci/acme 中还是能抄到不少的。

压缩本地的 ca 文件夹

cd $HOME/.acme.sh/ && tar cz ca | base64 -w0

安装 acme.sh

- name: Install acme.sh
  run: curl https://get.acme.sh | sh

解压 ca 文件夹

- name: Extract account files for acme.sh
  run: |
    echo "${{ secrets.ACME_SH_ACCOUNT_TAR }}" | base64 -d | tar -C ~/.acme.sh -xz

执行 acme.sh 申请证书

- name: Issue Certificate
  run: |
    export CF_Token="${{ secrets.CF_TOKEN }}"
    export CF_Zone_ID="${{ secrets.CF_ZONE_ID }}"
    export CF_Account_ID="${{ secrets.CF_ACCOUNT_ID }}"
    mkdir -p output
    ~/.acme.sh/acme.sh --issue --dns dns_cf --force -d ${{ env.domain }} --fullchain-file output/fullchain.pem --key-file output/key.pem

压缩证书

- name: zip Certificate
  run: |
    zip -j output/${{ env.domain }}_$(date +%Y%m%d).zip output/fullchain.pem output/key.pem

通过 tg bot 发送压缩包给自己

- name: Push Certificate
  run: |
    TG_BOT_TOKEN="${{ secrets.TG_BOT_TOKEN }}"
    TG_CHAT_ID="${{ secrets.TG_CHAT_ID }}"
    curl -s -X POST https://api.telegram.org/bot${TG_BOT_TOKEN}/sendDocument -F chat_id=${TG_CHAT_ID} -F document="@output/${{ env.domain }}_$(date +%Y%m%d).zip"

部署到又拍云

这里使用的是 menci/deploy-certificate-to-upyun。由于又拍云没有提供上传 ssl 证书的 api,因此只能通过模拟用户登陆的方式实现。

- name: Deploy To Upyun
  uses: Menci/deploy-certificate-to-upyun@beta-v2
  with:
    subaccount-username: ${{ secrets.UPYUN_SUBACCOUNT_USERNAME }}
    subaccount-password: ${{ secrets.UPYUN_SUBACCOUNT_PASSWORD }}
    fullchain-file: output/fullchain.pem
    key-file: output/key.pem
    domains: |
      ${{ env.domain }}
    delete-unused-certificates: true

SSL 证书成功部署到又拍云

参见

🔲 ☆

如何用requests库验证证书

用Python制作的程序怎么样?

起因

之前在抓包某些APP的时候,可能会遇到即使信任了抓包软件的CA根证书也无法抓包的情况,听说之所以遇到这种情况是因为那些APP使用了“SSL Pinning”的技术,可以只信任代码中认为可以信任的证书。不过对于逆向之类的事情我并不擅长,这种问题我也不太会解决。但是不能解决问题我可以创造问题啊,Java的APP我不会写,但是我会用Python写,所以今天来看看怎么样用Python实现类似“SSL Pinning”的技术。

实现方案

真正的SSL Pinning似乎是通过预置网站所使用的根证书或者中间证书来实现的,这样的好处是即使证书到期换了证书也能继续验证。不过我觉得其实没必要这么麻烦,一般Python程序要连接的后端也没必要在浏览器中调用,大不了就自签一个证书,然后自己验证证书就好了,反正中间人攻击重新签的公钥证书的指纹肯定和原来网站公钥证书的指纹不一样,用这一点就可以判断有没有被抓包。
不过我搜了一下,如果想实现这个功能,首先请求的时候就要获得网站的证书,很多资料都是直接用socket和ssl这两个包实现的,但是在python上请求一般都是用requests,用socket操作有点太麻烦了吧,再问问AI呢?AI给出的回复是:response.raw.connection.getpeercert(),结果执行了根本没有这个方法,不愧是只会东拼西凑,这应该是ssl库的函数吧……要么可以用urllib3.contrib.pyopenssl.ssl.get_server_certificate()这个方法获取,但是这个方法不是在发起请求的时候获取的证书,而是直接先访问了一下服务器然后直接获取的证书,这样每次调用接口的时候可能就要请求两次服务器了,感觉不怎么好……后来去Stack Overflow上搜了一下,还真有关于类似这个问题的讨论,于是我简单改编了一下,最终效果如下:

import requests
import hashlib

HTTPSConnection = requests.packages.urllib3.connection.HTTPSConnection
orig_HTTPSConnection_connect = HTTPSConnection.connect
def new_HTTPSConnection_connect(self):
    orig_HTTPSConnection_connect(self)
    try:
        self.peer_certificate = self.sock.getpeercert(binary_form=True)
    except AttributeError:
        pass
HTTPSConnection.connect = new_HTTPSConnection_connect

def verify_cert_request(url):
    with requests.get(url, stream=True, verify=False) as r:
        result = [ hashlib.sha256(r.raw.connection.sock.getpeercert(binary_form=True)).hexdigest(), r.text ]
    return result

result = verify_cert_request('https://www.baidu.com')
print(result[0])
print(result[1][:10])

用这个代码就能获取到请求的网站中证书的指纹了,如果不希望其他人抓包,先自己计算一下自己证书的hash指纹,然后在代码中执行逻辑的时候先判断一下请求网站的指纹是不是自己网站的指纹,如果不是还可以考虑一下反制措施?这样就能实现证书的验证了。

后记

不过Python作为解释型语言,代码不是随便看😂?就算用Cython然后加壳啥的调用的库依然不是加密的,大不了修改依赖的库然后让它返回的结果向正确的凑可能也行?不过这样至少能防止绝大多数抓包的人了。

🔲 ☆

网络安全科普:详解 HTTPS 与 TLS

引言

现在的网站都推荐使用 HTTPS 来确保用户数据的安全,验证网站的所有权,防止攻击者创建虚假网站版本,以及将信任传达给用户。如果网站要求用户登录、输入个人详细信息(例如其信用卡号)或查看机密信息(例如,健康福利或财务信息),则必须对数据保密。

作为普通用户,当我们上网冲浪时,是否想过为什么越来越多的域名输入时是 HTTPS 开头而非 HTTP 么?HTTPS 相比 HTTP 多出来的 S 到底多了些什么?TLS 和 SSL 又是什么,握手机制是如何进行的?其在七层协议中处于什么位置,与 HTTPS 相关的概念比如 CA 证书、Keyless、DTLS、mTLS 又分别是些啥?当我们使用 wireshark 时又该怎么抓包分析……种种疑问,希望能通过这篇文章的介绍为大家解答其中的部分解惑。

背景与介绍

什么是 HTTPS

超文本传输协议 (Hypertext Transfer Protocol, HTTP) 是设计用于在 Web 上传输内容的协议。 HTTP 是一种简单协议,它利用可靠的传输控制协议 (Transmission Control Protocol, TCP) 服务来执行其内容传输功能。由于数据在传输过程中是明文传输,因此无法保证网络通信在传输过程中不被篡改,安全性受到限制。

超文本传输安全协议 (HTTPS) 是 HTTP 的安全版本,但 HTTPS 并不是独立于 HTTP 的协议。它只是在 HTTP 协议的基础上使用 TLS/SSL 加密。HTTPS 经过加密,以提高数据传输的安全性。

为什么 HTTPS 可以保证安全

HTTPS 使用加密协议对通信进行加密。该协议称为传输层安全性 (TLS),但以前称为安全套接字层 (SSL)。该协议通过使用所谓的非对称公钥基础架构来保护通信。这种类型的安全系统使用两个不同的密钥来加密两方之间的通信:

  1. 私钥 - 此密钥由网站所有者控制,并且如读者所推测的那样,它是私有的。此密钥位于 Web 服务器上,用于解密通过公钥加密的信息。
  2. 公钥 - 所有想要以安全方式与服务器交互的人都可以使用此密钥。用公钥加密的信息只能用私钥解密。

HTTPS安全是由一套安全机制来保证的,主要包含这4个特性:机密性、完整性、真实性和不可否认性

  • 机密性是指传输的数据是采用 Session Key(会话密钥)加密的,在网络上是看不到明文的。
  • 完整性是指为了避免网络中传输的数据被非法篡改,使用 MAC 算法来保证消息的完整性。
  • 真实性是指通信的对方是可信的,利用了 PKI(Public Key Infrastructure 即「公钥基础设施」)来保证公钥的真实性。
  • 不可否认性是这个消息就是你给我发的,无法伪装和否认,是因为使用了签名的技术来保证的。

什么是 SSL/TLS

SSL(Secure Socket Layer)是指安全套接字层,简而言之,它是一项标准技术,可确保互联网连接安全,保护两个系统之间发送的任何敏感数据,防止网络犯罪分子读取和修改任何传输信息,包括个人资料。TLS(Transport Layer Security,传输层安全)是更为安全的升级版 SSL。

TLS 1.0 版实际上最初作为 SSL 3.1 版开发,HTTPS 是在 HTTP 协议基础上实施 TLS 加密,所有网站以及其他部分 web 服务都使用该协议。因此,任何使用 HTTPS 的网站都使用 TLS 加密。

使用了 SSL/TLS 之后,因为数据被非对称加密手段加密了,即使被截获,也是获取不到信息的。

TLS 协议实现的功能有三个主要组成部分:加密、认证和完整性。

  • 加密: 隐藏从第三方传输的数据。
  • 身份验证: 确保交换信息的各方是他们所声称的身份。
  • 完整性: 验证数据未被伪造或篡改。

为什么 SSL/TLS 可以保证安全

网站或应用程序要使用 TLS,必须在其源服务器上安装 TLS 证书(由于上述命名混淆,该证书也被称为 SSL 证书),而 TLS 连接是通过一个称为 TLS 握手的流程启动的,在这个过程中用户设备会和服务器交换确定信息,这些信息包括要使用的 TLS 版本、密码套件、TLS 证书、会话密钥等等。

而在浏览器端,当用户访问服务器页面的时候,浏览器会检查服务器的 SSL/TLS 许可是不是可用的,不可用的话,会提醒用户,这个网站不安全,也就是访问的数据可能被黑客截获;此时用户可以根据判断来不访问这个界面;许可都齐全的话,便可以安全的进行数据交互。

具体来说,SSL/TLS 在工作流程中通过如下三个方面保证安全性:

  • 通过 CA 体系交换 public key
  • 通过非对称加密算法,交换用于对称加密的密钥
  • 通过对称加密算法,加密正常的网络通信

术语表

为了方便大家阅读,特在此章将全文涉及到的一些专业术语进行整理,整理此术语表供大家查阅。

简称 英文全称 中文全称
CA Certificate Authority / Certification Authority 证书颁发机构
SSL Secure Sockets Layer 安全套接字层协议
TLS Transport Layer Security 传输层安全性协议
PKI Public key infrastructure 公钥基础设施
PCA Private Certificate Authority 私有证书颁发机构,又名私有 CA
HTTP Hypertext Transfer Protocol 超文本传输协议
HTTPS Hypertext Transfer Protocol Secure 超文本传输安全协议
- Public key 公钥
- Private key 私钥
OSI Open Systems Interconnection 开放网络互联模型
TCP Transmission Control Protocol 传输控制协议
DTLS Datagram Transport Layer Security 数据包传输层安全性协议
mTLS Mutual Transport Layer Security 双向 TLS
DH Diffie-Hellman key exchange 迪菲-赫尔曼密钥交换
- premaster secret 预主密钥

TLS 相关协议详解

HTTPS 协议组成

HTTP 协议是用于从网络传送超文本数据到本地浏览器的传送协议,HTTPS 协议简单讲是 HTTP 的安全版,在 HTTP 协议的基础上加入 SSL 层。

其中,HTTP的端口号为80, HTTPS的端口号为443,两者均为应用层协议,在 OSI 七层模型的最上层。

Untitled

HTTPS 与 CA 证书

在 HTTPS 的工作流程中,一个重要的角色便是证书,他的正常运行需要有一个证书(CA 证书):由专门的证书机构颁发。在知道了 TLS 的握手流程后,我们来看看证书在其中所起到的作用。

当客户端向服务端发送请求,请求中会向服务器提供以下信息:客户端支持的协议版本,比如TLS 1.0 版,此后,服务器返回经过 CA 认证的数字证书,证书里面包含了服务器的 public key,接下来证书在客户端的工作流程如下:

  • 客户端读取证书中的相关的明文信息,采用相同的散列函数计算得到信息摘要,然后,利用对应CA的公钥解密签名数据,对比证书的信息摘要,如果一致,则可以确认证书的合法性,即公钥合法;
  • 客户端然后验证证书相关的域名信息、有效时间等信息;
  • 客户端会内置信任 CA 的证书信息(此处一般指顶级 CA 的自签根证书,包含公钥),如果 CA 不被信任,则找不到对应 CA 的证书,证书也会被判定非法;此外,客户端还会通过 OCSP 对证书状态进行查询;
  • 客户端在完成对数字证书的验证后,用自己浏览器内置的 CA 证书解密得到服务器的 public key;
  • 客户端用服务器的 public key 加密一个预主密钥(这个密钥将在之后结合随机数等信息被用于客户端和服务端各自生成用于网络通信的对称密钥),传给服务器。因为只有服务器有 private key 可以解密,所以不用担心中间人拦截这个加密数据而获取其中的密钥信息;
  • 服务器拿到这个加密的密钥,解密获取信息,再依据双方约定好的参数生成对称加密密钥,并以此和客户端完成接下来的网络通信;

关于 TLS 证书/CA 证书的更多细节,可以参见文章《奇妙的 SSL/TLS 证书》,本文不再深入探讨。

TLS/SSL 握手与密钥交换

HTTPS 连接建立过程和 HTTP 差不多,区别在于 HTTP(默认端口 80) 请求只要在 TCP 连接建立后就可以发起,而 HTTPS(默认端口 443) 在 TCP 连接建立后,还需要经历 SSL 协议握手,成功后才能发起请求。

Untitled

TLS 1.2 握手流程

在 TLS1.2 以前,需要2-RTT时间进行握手,我们具体来看每一步操作:

  1. 第1步:整个连接是从客户端向服务器发送“Client Hello”消息开始。该消息由加密信息组成,与此同时客户端也会将本身支持的所有密码套件(Cipher Suite)列表发送过去,包括支持的协议和支持的密码套件,也包含一个随机值或随机字节串。
  2. 第2步:响应客户端的“客户端问候”消息,服务器以“Server Hello”消息响应。此消息包含服务器已从客户端提供的 CipherSuite 中选择的 CipherSuite。服务器还会将其证书以及会话ID和另一个随机值一起发送。
  3. 第3步:客户端验证服务器发送的证书。验证完成后,它会发送一个随机字节字符串,也称为“预主密钥”,并使用服务器证书的公钥对其进行加密。客户端向服务器发送“ChangeCipherSpec”消息,通过会话密钥的帮助让它知道它将切换到对称加密。与此同时,它还发送“客户端已完成”消息。
  4. 第4步:一旦服务器收到预主密钥,客户端和服务器都会生成一个主密钥以及会话密钥(临时密钥)。这些会话密钥将用于对称加密数据。在答复客户端的“更改密码规范”消息时,服务器执行相同操作并将其安全状态切换为对称加密。服务器通过发送“服务器已完成”消息来结束握手。

在客户端和服务器之间进行了两次往返(2-RTT)以完成握手。 平均而言,这需要0.25秒到0.5秒之间的时间。而这只是握手过程,实际的数据传输还没有开始。我们绘制示意图如下解释如上流程:

Untitled

TLS 1.3 握手流程

TLS1.3 在握手上做了优化,只需要一次时延往返就可以建立连接(1-RTT),其握手基本步骤为:

  • 客户端问候:客户端发送客户端问候消息,内含协议版本、客户端随机数和密码套件列表。由于已从 TLS 1.3 中删除了对不安全密码套件的支持,因此可能的密码套件数量大大减少。客户端问候消息还包括将用于计算预主密钥的参数。
  • 服务器生成主密钥:此时,服务器已经接收到客户端随机数以及客户端的参数和密码套件。它已经拥有服务器随机数,因为它可以自己生成。因此,服务器可以创建主密钥。
  • 服务器问候和“完成”:服务器问候包括服务器的证书、数字签名、服务器随机数和选择的密码套件。因为它已经有了主密钥,所以它也发送了一个“完成”消息。
  • 最后步骤和客户端“完成”:客户端验证签名和证书,生成主密钥,并发送“完成”消息。
  • 实现安全对称加密:依据双方约定好的参数生成的对称加密主密钥,将被用在接下来客户端和服务端的网络通信中;

TLS 1.3 的核心宗旨是简单性。在新版本中,除去了 Diffie-Hellman(DH)密钥交换以外的所有密钥交换算法。简而言之,DH 算法(Diffie-Hellman 算法)可以保证在双方不直接传输原始密钥的情况下,完成双方密钥交换。

TLS 1.3 还定义了一组经过测试的 DH 参数,无需与服务器协商参数。由于只有一个密钥交换算法(具有内置参数)和少数支持的密码,因此设置 TLS 1.3 通道所需的绝对带宽比早期版本要少得多。示意图流程如下:

Untitled

基于 UDP 的 TLS 协议:DTLS

DTLS 介绍

DTLS 是基于 UDP 场景下数据包可能丢失或重新排序的现实情况下,为 UDP 定制和改进的 TLS 协议。

Untitled

DTLS 协议由两层组成: Record 协议 和 Handshake 协议

  1. Record 协议:使用对称密钥对传输数据进行加密,并使用 HMAC 对数据进行完整性校验,实现了数据的安全传输。
  2. Handshake 协议:使用非对称加密算法,完成 Record 协议使用的对称密钥的协商。

DTLS 与 TLS 握手差异

与 TLS 的握手流程相比,DTLS 的握手流程存在如下几点差异(左为 TLS 握手,右为 DTLS 握手):

Untitled

  1. HelloVerifyRequest 用于服务端对客户端实现二次校验;DTLS 的 RecordLayer 新增了 SequenceNumber 和 Epoch,以及 ClientHello 中新增了 Cookie,以及 Handshake 中新增了 Fragment 信息(防止超过 UDP 的 MTU),都是为了适应 UDP 的丢包以及容易被攻击做的改进;
  2. Certificate 是交换的证书,由协商后的算法确定是否需要传输;
  3. TLS 没有发送 CertificateRequest,这个也不是必须的,是反向验证即服务器验证客户端;当服务端要求验证客户端身份时,发起 CertificateRequest,此时客户端需要发送证书;
  4. ChangeCipherSpec 是一个简单的标记,标明当前已经完成密钥协商,可以准备传输;
  5. Finished 消息表示握手结束,通常会携带加密数据由对端进行初次验证。

DTLS 的握手防护、加密方式与应用场景

在实现机制上,DTLS 还存在几个特点,首先是握手防护机制,在这个机制中,除了上文说到的防止被攻击等在握手阶段引入的改进措施外,还有一个机制叫重传:

  • DTLS 每一方在每次握手中传送的第一个消息总是有 message_seq = 0。每当有新消息产生时,message_seq 的值就会增加1。
  • DTLS需兼容多种出错场景,出错时往往直接丢弃处理,而在 TLS 中,如果出错,则会中断连接;

以握手的第一阶段举例,客户端发送 Client Hello(不带 Cookie,区别于握手流程中的第二次 Client Hello)之后,启动一个定时器,等待服务端返回 HelloVerifyRequest,如果超过了定时器时间客户端还没有收到 HelloVerifyRequest,那么客户端就会知道要么是 Client Hello 消息丢了要么是 Hello Verify Request 消息丢了,客户端就会再次发送相同的 Client Hello 消息,即使服务端确实发送了 Hello Verify Request 还是收到了 Client Hello 消息,它也知道是需要重传,并再次发送 Hello Verify Request 消息,同样地,服务端也会启动定时器来等待下一条消息。

由于 DTLS 依赖 UDP,而 SSL/TLS 依赖 TCP,所以两者在加密方式上存在如下差异:

  1. SSL/TLS 不能独立解密单个封包,SSL/TLS 对于封包的认证需要序号作为输入,在 SSL/TLS 中并未直接传递序号,因为 TCP 是可靠的,所以 SSL/TLS 的两端各自维护自身的收发序号;
  2. DTLS 支持独立解密,其通过在每条记录中显式携带的序号作为解码的输入;此外,由于算法加解密的限制,DTLS所支持的加密算法为 TLS 的子集。

DTLS 主要被用在 WebRTC 协议中,以保证媒体传输的安全性。

客户端身份校验与 mTLS

在基础的 TLS 认证流程中,大量的场景都是确保用户访问的是真正的服务方,如:银行、电商网站等等,即保证用户不会被钓鱼网站或是中间人攻击,此时我们只需要在客户端侧对服务端身份进行校验。

SSL 连接中的客户端身份校验

在典型的 SSL 连接场景中,通过 HTTPS 连接到服务器的客户端会检查服务器的有效性,它会在启动 SSL 握手时检查服务器返回证书的有效性。但是,有时你可能希望将服务器配置为对与其连接的客户端进行身份验证。这样,你便可以只允许使用证书进行身份验证的用户访问 web 服务器上的资源。

启用客户端身份校验的两个基本步骤:

  1. 客户端需要获取、并安装经过 CA 认证的 SSL/TLS 证书
  2. 服务端需要开启客户端身份验证选项

Untitled

由于客户端安装证书的不方便性、成本高以及双因素身份验证的快速推行,客户端身份验证并没有得到广泛使用。

mTLS 介绍

很多时候,比如在一些支付场景下,我们和支付机构、商户构成的三者关系中,在商户和支付机构间需要保证相互的身份认证,以保证支付信息在两者间正常流转,这时便需要我们使用双向认证,即相互 TLS。

相互 TLS,简称 mTLS,是一种相互身份验证的方法。mTLS 通过验证他们都拥有正确的私人密钥来确保网络连接两端的各方都是他们声称的身份。他们各自的 TLS 证书中的信息提供了额外的验证。

mTLS 与 TLS 流程的区别

一个典型的 TLS 流程应该包含如下几步:

  1. 客户端连接到服务器
  2. 服务器出示其 TLS 证书
  3. 客户端验证服务器的证书
  4. 客户端和服务器通过加密的 TLS 连接交换信息

相比之下,mTLS 会在客户端验证服务器证书后加上几步,以保证服务端可以对客户端身份进行验证,并授予访问权限,一个完整的 mTLS 流程如下:

  1. 客户端连接到服务器
  2. 服务器出示其 TLS 证书
  3. 客户端验证服务器的证书
  4. 客户端出示其 TLS 证书
  5. 服务器验证客户端的证书
  6. 服务器授予访问权限
  7. 客户端和服务器通过加密的 TLS 连接交换信息

mTLS 的应用场景与安全性

由于公共互联网上亟待解决的问题是防止被访问的网站内容被篡改,保证用户不会访问到钓鱼网站;此外,再加上将 TLS 证书分发到所有终端用户设备上是非常困难的现状,mTLS 并没有被运用到整个互联网。mTLS 对于较小范围内的组织验证和通信非常有用,除了验证信息外,他还可以防止各种类型的攻击,比如在途攻击、网络钓鱼攻击以及恶意 API 请求等等。

mTLS 通常被用于零信任安全框架,以验证组织内的用户、设备和服务器。它也可以帮助保持 API 的安全。

HTTPS 与 Keyless SSL

虽然 HTTPS 可以解决传输的安全性但是引入了私钥的安全和管理问题。只要拿到了私钥,HTTPS 就如同虚设。尤其是在传统 CDN 加速场景下,需要将私钥证书同步给 CDN,无形中增加了风险,所以诞生了 Keyless 技术。这项技术可以使得客户在使用 CDN 进行 HTTPS 加速时保留其自身 SSL 的私钥,在仅有公钥的场景下顺利完成 SSL 握手。

在 Keyless 的作用下,服务端不解密密文,而是将经加密后的预主密钥(premaster secret)等数据打包发送给远端 key server,由其进行加工并返回数据;针对 DH 协商密钥的流程,服务端则负责将 DH 参数、服务端随机数、客户端随机数等数据传给 key server,由 key server 处理后返回 DH 数据以及证书等信息。

以 CloudFlare 为例,我们看看 Keyless 在部署时有设计哪些额外的流程以保证安全。为了使 Keyless SSL 安全,CloudFlare 边缘到 key server 的连接也需要安全。key server 可以为所有能够访问它的人提供私钥操作,就像一个密码数据库。保证只有 CloudFlare 可以访问 key server 来执行操作对 Keyless SSL 至关重要。

CloudFlare 通过相互认证的 TLS 机制来保证 CloudFlare 和 key server 之间的连接安全。在握手环节一章,我们提到了客户端对服务端的单方向身份认证,是通过验证 CA 证书来进行的,而后我们也提到了客户端身份校验,这在这里便派上了用场。在 TLS 双向认证中,客户端和服务端都由对方的证书并相互认证。

在 Keyless SSL 中,key server 仅允许携带 CloudFlare 内部签发的证书的连接。CloudFlare 使用我们自己签发的证书来进行双向认证。

在这里,我们以 RSA 为例,如下两图展示了在接入 Keyless 前后,TLS 握手过程的变化差异。

Untitled

Untitled

RFC 协议标准

本文介绍的各类协议均有对应的 RFC 标准,罗列于此供读者参考:

  1. TLS1.3 https://www.rfc-editor.org/rfc/rfc8446
  2. DTLS1.2 https://www.rfc-editor.org/rfc/rfc6347.html
  3. HTTPS https://www.rfc-editor.org/rfc/rfc2818.html
  4. MTLS https://www.rfc-editor.org/rfc/rfc8705

HTTPS 流量抓包与分析

利用 wireshark 我们可以对 https 流量进行抓包分析,下载地址见 https://www.wireshark.org/#download

操作与步骤

通过 Wireshark 我们可以很好的过滤 HTTPS 流量,比如如下常见的几个过滤条件:

// TLS加密传输数据的过滤器
ssl.record.content_type == 23  and tcp.dstport == 443

// TLS 建立连接
ssl.handshake.type == 1

// 在知道 IP 地址为A时的指定 TLS 流量
ip.addr == A && tcp.port == 443

但对于 HTTPS 流量,如果需要解密,我们还需要增加如下几个步骤来达成目的:

  1. 在终端打开你的 chrome 浏览器,并指定你的 SSL key 的 log 日志存放地址,假设地址为A;
  2. 在 WireShark 中,通过路径 Wireshark - Preferences - Protocols - TLS ,在 (Pre)-Master-Secret log filename 输入同样的地址 A,并保存;
  3. 然后通过第一步打开的浏览器访问 https 协议的网站地址,并在 wireshark 中指定 filter 例如 tls && ip.addr=1.1.1.1 来过滤指定 tls 流量;

通过 Wireshark 抓包,以一个 TLS1.2 版本的 ClientHello 消息报文内容为例,我们可以看到其中包含了客户端支持的 TLS 版本、加密套件、以及用于连接的 SNI 等信息

Untitled

同样的,以一个 TLS1.2 版本的建立连接为例,我们可以看到 Server 在返回 Server Hello 之后还返回了证书、Server Key Exchange 以及 Server Done 等信息

Untitled

应用与拓展场景

实现 Charles 抓包代理

用过抓包工具的人都知道,比如 Charles,Fiddler 是可以抓取 HTTPS 请求并解密的,它们是如何做到的呢?

简单来说,Charles 作为一个“中间人代理”,当浏览器和服务器通信时,Charles 接收服务器的证书,然后动态生成一张证书发送给浏览器,也就是说 Charles 作为中间代理在浏览器和服务器之间通信,所以通信的数据可以被 Charles 拦截并解密,这包括服务器证书公钥和 HTTPS 连接的对称密钥。

由于 Charles 更改了证书,浏览器校验不通过会给出安全警告,所以 Charles 可以正常工作的前提在于必须安装 Charles 的证书,使系统信任。

为开发环境启动 HTTPS

在本地开发功能模块时,可能有些 Web API 前置要求环境为 HTTPS,这要求我们在本地开发环境也能够配置 HTTPS,OpenSSL 是 SSL 和 TLS 协议的开放式源代码实现,使用它,我们可以在本地生成一个自签名证书,以方便开发。如下一行命令可以帮助我们生成一个 HTTPS 证书以及私钥:

openssl req -newkey rsa:2048 -nodes -keyout key.pem -x509 -days 365 -out certificate.pem

社区里有一个开源库叫 mkcert,可以更便捷的为你生成 TLS 证书,这里不深入介绍,感兴趣可以移步 https://github.com/FiloSottile/mkcert 查看更多。

注:为了能够正常使用,本地生成的证书需要被加入信任列表才能正常使用。

参考

  1. https://www.cloudflare.com/zh-cn/learning/ssl/what-happens-in-a-tls-handshake/
  2. https://tech.bytedance.net/articles/7166221771396349982
  3. https://blog.csdn.net/qzcsu/article/details/72861891
  4. https://tinychen.com/20200602-encryption-intro/
  5. https://comodosslstore.com/blog/what-is-ssl-tls-client-authentication-how-does-it-work.html
  6. https://web.dev/i18n/zh/how-to-use-local-https/
  7. https://blog.cloudflare.com/announcing-keyless-ssl-all-the-benefits-of-cloudflare-without-having-to-turn-over-your-private-ssl-keys/
  8. https://blog.cloudflare.com/keyless-ssl-the-nitty-gritty-technical-details/
  9. https://tech.bytedance.net/articles/6915326021995200519
  10. https://www.cloudflare.com/zh-cn/learning/access-management/what-is-mutual-tls/
🔲 ⭐

内网 HTTPS 可信证书

前言

内网 HTTPS 化的必要性

  开发团队或者公司内部一般会采用内外网隔离、上网行为过滤等措施,比较可靠地保证了内部设备无法被外部网络所侦测,从而可能认为 HTTP 内网站点是一个相对安全的存在。即使在 HTTPS 证书如此盛行的今天,也还暂时不考虑内部站点的 HTTPS 化。IP + Port 或者 http://本地域名 的访问方式依旧是座上宾。当然,如果考虑到购买 HTTPS 证书的成本或者团队内网站点采用 Letsencrypt 等免费证书过于麻烦(只能采用 DNS 验证的方式每三个月申请一次新证书),那么自签名 SSL 证书则成为首选了。不过,如果为每一个内网站点都生成一个 SSL 证书,然后让大家都手动把 HTTPS 标为可信,那么当面临大量内网站点时,大家可能要被搞崩溃。更为可行的办法是,生成一个内网用的根证书,只标记该根证书可信

根证书

  与其相信别人根证书生成的 SSL 证书,不如相信自己根证书生成的。我们的目的毕竟不是要任何一个人都把我们自签名的证书标为可信,只要在内网内使用内网站点的设备能够信任即可。而且成为一个受到公众信任的根证书是非常困难的一件事,即使经过几十年可能也没有办法做到。如今现有的根证书实际上有限,像我们平常熟悉的 Letsencrypt、ZeroSSL、Cloudflare 等等并不是根证书而是中间证书。有点类似总代理和分代理的感觉,根证书在业界具有广泛的公信力,但是让根证书去给个人或者企业生成证书可能有点忙不过来。于是根证书生成若干个中间证书,再由中间证书来为个人或者企业生成实际的证书。

  一般来说,操作系统或浏览器的产商会预置国际上认可的根证书。如下所示,为 Mac OS 上预置的根证书列表。

Mac OS 预置根证书 Root Certificate

实践

  话不多说,让我们来实践一下如何生成自己的根证书和签发 SSL 证书吧。

生成根证书

安装 OpenSSL (可选)

  此处只考虑 Mac OS 和 Ubuntu,其他环境如何安装可以自行搜索。

# Mac OS
brew install openssl

# Ubuntu
sudo apt install -y openssl

创建根密钥

  使用以下命令创建根密钥 zhonger-key.pem

openssl genrsa -out zhonger-key.pem 4096

创建根证书并签名

  使用刚创建好的根密钥 zhonger-key.pem 生成根证书,并输入相关信息。

openssl req -new -x509 -days 3600 -key zhonger-key.pem -out zhongerca.pem
╰─$ openssl req -new -x509 -days 3600 -key zhonger-key.pem -out zhongerca.pem 
You are about to be asked to enter information that will be incorporated
into your certificate request.
What you are about to enter is what is called a Distinguished Name or a DN.
There are quite a few fields but you can leave some blank
For some fields there will be a default value,
If you enter '.', the field will be left blank.
-----
Country Name (2 letter code) [AU]:CN
State or Province Name (full name) [Some-State]:Shanghai
Locality Name (eg, city) []:Shanghai
Organization Name (eg, company) [Internet Widgits Pty Ltd]:zhonger
Organizational Unit Name (eg, section) []:zhonger
Common Name (e.g. server FQDN or YOUR name) []:lisz.me
Email Address []:contact@lisz.me

验证根证书

$ openssl x509 -text -in zhongerca.pem -noout
Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number:
            44:48:03:56:ff:15:57:03:00:34:1f:85:61:ca:f7:7a:1e:4f:38:8f
        Signature Algorithm: sha256WithRSAEncryption
        Issuer: C = CN, ST = Shanghai, L = Shanghai, O = zhonger, OU = zhonger, CN = lisz.me, emailAddress = contact@lisz.me
        Validity
            Not Before: Aug  3 05:25:47 2022 GMT
            Not After : Jun 11 05:25:47 2032 GMT
        Subject: C = CN, ST = Shanghai, L = Shanghai, O = zhonger, OU = zhonger, CN = lisz.me, emailAddress = contact@lisz.me
        Subject Public Key Info:
            Public Key Algorithm: rsaEncryption
                RSA Public-Key: (4096 bit)
                Modulus:
                    00:b2:d1:47:73:8a:83:48:e3:47:1a:41:01:f6:63:
                    69:43:39:71:eb:2b:74:be:dc:63:f3:df:79:66:ee:
                    00:30:65:b3:4f:7e:58:88:00:13:09:e6:4f:74:57:
                    fa:a3:56:24:cd:b6:1f:53:25:77:98:bf:9f:45:64:
                    7c:6c:04:23:c4:8f:0f:bf:2e:b3:d1:2e:4c:05:4d:
                    4c:e6:65:54:ad:0c:35:b7:d9:c8:74:97:19:c7:a5:
                    cd:9a:a4:73:37:13:71:80:34:7c:bc:b3:41:5a:34:
                    bb:16:82:44:18:a1:0a:a5:f5:f1:07:ca:8d:b3:9a:
                    ef:74:fb:a0:6c:72:4a:53:5c:59:74:6f:aa:c7:bc:
                    48:26:af:1b:70:f3:5f:7f:c7:df:8d:e5:da:e4:f4:
                    d2:fa:90:d3:e2:67:e1:9a:df:c7:c4:c7:53:6f:62:
                    25:ed:ff:0a:17:cf:8d:4d:84:6b:38:cb:49:e7:3d:
                    c5:2b:15:76:e6:eb:cc:17:94:40:20:7d:ee:8c:36:
                    6d:cf:9c:d7:1f:a6:41:20:9d:45:cd:57:8f:a8:61:
                    f8:8b:e9:31:6a:a9:96:c1:db:57:64:0b:09:da:ca:
                    b3:07:d9:55:ed:fe:69:a0:9c:78:5b:59:a5:7b:a1:
                    2b:4d:68:22:b4:7f:db:c6:c1:12:ee:eb:9b:29:38:
                    ae:7b:4c:0d:2a:ab:33:3f:af:a8:7b:ca:89:2c:62:
                    0f:a8:ef:89:60:9e:fd:a2:df:36:6d:70:82:8b:fa:
                    b3:ee:79:7e:fd:3f:e7:90:84:58:85:7e:7e:69:07:
                    1e:50:05:0b:87:4d:66:e4:17:6b:c2:97:03:48:e4:
                    7d:08:b4:81:a6:05:80:60:5c:eb:8d:53:db:7c:62:
                    a8:6d:a7:75:f1:56:b6:d9:0d:6b:3b:be:8b:72:39:
                    8d:e7:2d:77:74:e3:4d:a1:fd:8b:44:f9:ee:fd:0d:
                    04:ec:6a:fc:f3:d2:15:fc:18:ff:7d:33:44:2b:6d:
                    7f:3c:33:21:e1:d8:5f:08:fa:53:fd:26:fb:6e:74:
                    d7:4b:51:62:d3:15:1b:3b:44:78:78:9b:91:c7:ba:
                    82:2b:12:d7:b2:83:0a:39:ec:5e:a9:a9:c1:04:a6:
                    2e:64:a5:ea:15:c3:85:e9:ac:38:6b:22:eb:3b:08:
                    b8:0a:31:10:df:45:1d:76:81:e0:0f:88:e4:00:ef:
                    6e:90:59:8c:d8:36:e9:77:bf:4a:0e:3d:03:02:4d:
                    5d:a7:90:16:81:11:e0:81:bb:e0:18:a3:bb:dc:8d:
                    7d:c6:cf:c6:0b:d2:80:53:ea:d0:27:e6:6a:cc:8e:
                    2b:b3:72:e4:ab:84:88:e2:e9:a5:bb:72:9a:c6:a2:
                    0e:5a:cb
                Exponent: 65537 (0x10001)
        X509v3 extensions:
            X509v3 Subject Key Identifier:
                EE:EF:AE:DB:73:45:9A:6E:82:00:3C:A7:05:0D:60:E4:20:81:3B:02
            X509v3 Authority Key Identifier:
                keyid:EE:EF:AE:DB:73:45:9A:6E:82:00:3C:A7:05:0D:60:E4:20:81:3B:02

            X509v3 Basic Constraints: critical
                CA:TRUE
    Signature Algorithm: sha256WithRSAEncryption
         98:cf:f4:23:61:d2:2a:64:ce:51:57:1d:fb:61:2f:34:68:86:
         c9:02:5a:c8:97:80:58:c1:7f:04:e1:97:f5:0b:35:d5:c4:91:
         fa:98:8c:73:16:43:b3:af:63:af:2c:30:cf:6a:8e:10:99:bc:
         fd:3d:84:c7:3d:01:e0:8d:8d:d8:76:74:12:69:1a:f5:e5:ec:
         ef:eb:dc:f8:08:0c:c7:03:19:de:c5:e8:c7:4e:b4:5c:67:39:
         9f:33:11:6f:29:e1:03:d8:4e:70:09:7a:69:bd:3a:db:96:71:
         2b:38:c4:46:87:f6:59:34:f9:dc:5c:6d:34:9a:ba:ea:36:13:
         d8:e3:e3:91:ea:70:3b:ea:39:cb:fc:fd:08:0f:73:e5:16:c3:
         0d:9a:62:20:3f:5a:28:90:e6:b2:65:23:a1:ba:d0:77:c0:8e:
         16:51:55:44:f6:4b:16:b9:a1:97:bc:f8:95:70:af:a6:d4:07:
         27:21:96:78:0b:58:18:51:45:a6:ea:07:c8:09:1b:ad:f3:e1:
         16:be:64:bf:8f:b7:4c:d1:e6:d0:c6:c1:db:cd:3d:e9:88:ec:
         e2:87:ff:bd:c3:7b:31:23:00:c3:71:53:90:68:46:99:7d:1d:
         e1:78:26:76:6a:41:8d:9e:9a:55:97:63:a5:df:86:fc:03:9b:
         28:13:55:ff:74:f2:56:d9:20:02:e8:c9:90:4f:b1:5d:1b:66:
         57:4e:f7:c6:50:4f:c9:8b:ff:39:a1:9e:b4:ee:2b:8a:bf:46:
         b4:3e:65:cb:34:12:73:bc:ae:ba:a5:41:20:d4:b9:c5:c4:da:
         89:bd:50:83:27:71:7a:9f:2c:3e:cf:de:db:13:b1:39:cf:4a:
         39:62:68:b3:f5:dc:49:44:3e:c1:cf:0c:a4:9a:4b:cb:5e:ec:
         aa:33:a5:57:ae:c6:f3:4f:69:01:d1:6a:a7:12:90:88:05:e9:
         18:d8:3a:a7:89:70:55:ab:18:ba:4f:28:74:5b:5f:21:8e:66:
         bc:ae:ff:1b:c7:ed:42:73:c1:1c:a4:97:f2:e6:c7:5a:8f:a8:
         44:a5:ed:b7:76:ac:cf:40:f0:a4:4f:22:03:d0:db:db:6e:18:
         32:33:4a:79:c2:bb:98:20:71:03:a7:9c:ea:4e:7e:0a:28:79:
         30:f3:3f:ef:03:b2:e0:00:b0:2b:71:27:8b:fc:f9:a0:e5:b9:
         a0:9e:6f:93:3a:f3:d3:1c:87:8a:b7:2d:5c:38:ab:f9:ff:39:
         8b:52:a5:9a:95:2f:a0:82:b9:b6:f8:9a:c3:e3:55:dd:4b:b5:
         e4:e3:fb:f8:8b:10:50:f8:42:7d:03:fe:72:40:c1:d3:f7:26:
         a7:f9:de:b9:9d:30:26:94

安装根证书

  首次打开刚刚生成的根证书 zhongerca.pem 会像下面这样显示“此根证书不被信任”,我们可以将下面的使用此证书时的使用系统默认改成始终信任,然后输入操作系统用户密码即可保存修改。改完之后再次打开如下下图所示,显示“此证书已标记为受此账户信任”。这样一来,由该根证书签发的证书就都会被信任了。

打开根证书 Open Root Certificate file

始终信任根证书 Always trust Root Certificate file

签发证书

  这里我们打算采用 jsha/minica 来辅助快速签发证书。

安装 minica

# Mac OS
brew install minica

# Other OS
go install github.com/jsha/minica@latest

签发 SSL 证书

# 给域名签发 SSL 证书
minica -ca-cert zhongerca.pem -ca-key zhonger-key.pem --domains "sni.lisz.me,zhonger.io,*.zhonger.io"

# 给 IP 签发 SSL 证书
minica -ca-cert zhongerca.pem -ca-key zhonger-key.pem --ip-addresses "127.0.0.1"

  minica 提供了非常简单的方式来签发 SSL 证书,比如说指定根证书和根密钥、指定单个或多个域名、通配符域名以及 IP。minica 签发的证书默认时效为 2年30天(相信可能是考虑到 30天 的缓冲期所以多了一个月)。这里,我们模仿了 Cloudflare 的 SSL 证书生成方式,第一个域名是 sni.根证书域名,第二个开始才是真正想要签发的域名。由于 minica 默认会将第一个域名作为文件夹的名字生成 SSL 证书 cert.pemkey.pem 文件,如果采取这种方式在同一目录执行以上签发命令势必会使得旧文件被覆盖,因此推荐像 Certonly 或者 acme.sh 那样修改目录名来区分。

其他

mkcert

  除了 minica 之外,FiloSottile/mkcert 提供的 mkcert 工具也非常简单方便。mkcert 会自行生成根证书,然后签发证书。个人觉得,相比 minica 而言,mkcert 更适合个人本地开发 HTTPS 化,而非团队内网 HTTPS 化。

根证书被伪造

  自生成根证书比较令人担心的地方可能就是任何人都可以用同样的方法伪造出相似的根证书。实际上,根证书是独一无二的,即使所有的信息都设置成一样,还是两个根证书。我们需要做的是:

  • 保护好根证书密钥,因为采用同一个密钥是可以生成比较相似的根证书的。当然两个根证书的序列 ID有效时间是不会完全一样的。如果密钥不同,自然两个根证书的密钥 ID 也不会相同。
  • 告诉用户真的根证书是什么样的(序列 ID、密钥 ID、有效时间等)、应该从哪里下载到。这里需要在内网建立一个用于提供下载根证书的站点,而这个站点的SSL 证书最好采用购买的或申请的证书。也就是说,从可靠站点下载的内网 HTTPS 化根证书也是可靠的。

  如下所示,是上面生成的根证书和签发的 SSL 证书的密钥 ID 对比。可以看到,两者完全一样,即可信任的 SSL 证书。

根证书密钥 ID Root Certificate Key ID

SSL 证书密钥 ID the Key ID in SSL Certificate

其他平台安装根证书

  上面已经提到了在 Mac OS 中如何安装根证书,其他平台比如 Windows、Linux、Android、IOS 等也是可以按照类似的方式,略微有些差别。考虑到 Windows 和 Linux 桌面版安装根证书的步骤几乎与 Mac OS 一样就不再赘述,这里主要讲一下 Linux Server、Android、IOS 平台的安装方法。

Linux Server

sudo cp zhongerca.pem /usr/local/share/ca-certificates/zhongerca.pem
sudo update-ca-certificates

Android 或 HarmonyOS

  以华为鸿蒙系统(HarmonyOS)为例,首先下载根证书到设备上,然后在 设置 > 安全 > 更多安全设置 > 加密和凭据 > 从存储设备上安装 中选择已下载的根证书完成安装。安装完成之后可以在同级别的 受信任的凭据 > 用户 下面看到安装好的根证书。

IOS 或 iPad OS

  首先下载根证书到设备上,在文件中点击打开(会自动跳转到 设置 > 通用 > VPN与设备管理)。可以在 配置描述文件 列表中看到根证书,点击进去输入密码并验证即可。

参考资料

🔲 ⭐

创建自签名 SSL 证书

疫情仍然在继续,但是维州已经开始恢复生机。我大概又歇了小半年,除了工作时间,下班之余几乎没有碰与技术相关的东西。同时由于近来关注了太多社会新闻,反而让自己陷入了政治性抑郁。刷推的时候刷到一个无聊的放置类手游竟随手下载来玩了一个星期,也算放空一下自己,转移注意力,好重振旗鼓。 回顾了一下半年多以前开发的 Axidraw Web,有不少可以分享的点东西,准备挑几个记录一下。

AxiDraw 是一个双电机的二维绘图仪。可以将 SVG 绘制到纸面上。在研究完它的底层串口协议后,我尝试用 javascript 实现了一个能在网页上运行的 Web 小程序,能通过 WebUSB API 与 AxiDraw 单片机直接进行通讯,同时实现了 SVG 的绘制功能。

WebUSB

在开发 Axidraw Web 的时候,由于 WebUSB API 需要在安全的浏览器环境下(Secure Contexts)才能使用。在较早的 Chrome 浏览器上(version 83 之前),localhost 是否属于 Secure Contexts 尚未确定。而这类 API 默认只对 https://localhost 开放。在我写这篇文章的时候,文档中已经明确将 localhost 定义为安全环境。如果在开发环境中使用了较老的浏览器或者其它的自定域名,还是需要自行提供 TLS 支持。主要工作就是提供一个自签名证书,然后在 web server 中使用:

import express from 'express';
import https from 'https';
import fs from 'fs';

const app = express();
app.use(express.static('dist'));

const options = {};

try {
  options.key = fs.readFileSync('server/cert/dev.key');
  options.cert = fs.readFileSync('server/cert/dev.crt');
} catch (e) {
  console.error('Please create and install the SSL cert first.');
  process.exit(1);
}

const server = https.createServer(options, app).listen(8443);
🔲 ☆

使用Caddy2托管静态博客

最近由于Cloudflare受到一些”干扰”,有网友提醒我的blog在国内有打不开的情况。不幸的是我的Blog + CDN都是托管给Cloudflare的,被无辜”殃及”了。再加上最近比较忙,blog也荒芜了快一年没打理,正好这个周末的晚上可以来折腾一下。

看了一下手上的吃灰的几个小鸡,发现竟然还有一台之前买的搬瓦工的小鸡,登录上去一看,好家伙,uptime 都659天了,稳啊! 好吧,就它了!接下来又是一波骚操作,从德国Contabo的主机rsync直接拷贝所有静态页面过来,速度还挺快。之前用的Nginx,这次打算再次换成Caddy Server,毕竟Caddy都出第二个版本了,自动更新SSL证书是比较能吸引我的地方。去官网研究了一下文档,还是决定用Docker来跑Caddy2,方便!

由于这个VPS只是用来放我的静态blog,所以也不用考虑多站点的问题,Caddyfile的配置简单到发指:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
https://xiaozhou.net
{
encode gzip
tls example.mail@gmail.com {
protocols tls1.2
}
root * /usr/share/caddy
file_server
}

http://www.xiaozhou.net
{
redir https://xiaozhou.net{url}
}

http://xiaozhou.net
{
redir https://xiaozhou.net{url}
}

配置里面有两个请求转发,把所有非HTTPS的请求都转发到HTTPS。然后,再准备一个shell脚本,用来运行Caddy2:

1
2
3
4
docker run -d --restart=always --name caddy -p 80:80 -p 443:443 \
-v /home/mysite:/usr/share/caddy/ \
-v $PWD/Caddyfile:/etc/caddy/Caddyfile \
caddy:2.4.5-alpine

容器直接暴露80端口和443端口到宿主机器即可。万事具备,只欠东风!去Cloudflare把域名解析过来,等生效,然后直接启动Caddy server。一切顺利,Caddy Server会在第一次启动的时候,自动申请LetsEncrypt的证书,后续也能自动renew。我懒啊,要的就是省心!

后续的流程,去GitHub改掉blog的自动部署流程,把部署的机器更新成现在的小鸡,打完收工!就这样,Blog又又又搬家了一次,从德国搬去了美国西海岸…… 唉……

🔲 ⭐

Get free ZeroSSL wildcard SSL certificates with acme.sh DNS API

ACME stands for Automatic Certificate Management Environment and provides an easy-to-use method of automating interactions between a certificate authority (like Let’s Encrypt, or ZeroSSL) and a web server. With ZeroSSL’s ACME feature, you can generate an unlimited amount of 90-day SSL certificates (even multi-domain and wildcard certificates) without any charges.

Create ZeroSSL account

Visit ZeroSSL official site to register an account. All certificates issued with ACME will be stored in your ZeroSSL account dashboard for easy management (after acme.sh register).

Install acme.sh

acme.sh is an ACME protocol client written purely in Shell. It works on any Linux server without special requirements.

Update your Linux repo with latest CA bundle and patches from System Update else some issues will occur when generating your free SSL. Once completed then begin the below procedure.

Now install it from https://get.acme.sh:

curl https://get.acme.sh | sh -semail=<email@exmaple.com>

source ~/.bashrc

# if you're using zsh# source ~/.zshrc

That’s it. For advanced installation, visit [https://github.com/acmesh-official/acme.sh/wiki/How-to-install] for details.

Then, you can register your ZeroSSL account with acme.sh:

acme.sh --register-account-m <email@example.com> --server zerossl

It’s strongly recommended to enable the auto upgrade acme.sh by:

acme.sh --upgrade--auto-upgrade

Then acme.sh will be kept to the latest release automatically.

Stop auto upgrade by acme.sh --upgrade --auto-upgrade 0.

Config DNS API

Just use Cloudfare as an example, other DNS providers’ configurations can be found at https://github.com/acmesh-official/acme.sh/wiki/dnsapi.

First, you need to log in to your Cloudflare account to get your API key.

You can narrow the Cloudflare’s API token that is only for writing access to Zone.DNS for a single domain, then update variables in your environment by running the following commands in the shell (these variables will be saved by acme.sh):

exportCF_Token="xxxxxxxxxxxxxxxxxxxxxxxxxx"exportCF_Account_ID="xxxxxxxxxxxxx"exportCF_Zone_ID="xxxxxxxxxxxxx"

You may not need CF_Zone_ID if your token is set to edit multiple DNS Zones.

Issue a wildcard cert:

Okay, after these configurations we can now issue certs with:

acme.sh --issue--dns dns_cf -d example.com -d*.example.com

The CF_Token, CF_Account_ID, and CF_Zone_ID will be saved in ~/.acme.sh/account.conf and will be reused when needed.

We can also get wildcard certs for subdomains, e.g.:

acme.sh --issue--dns dns_cf -d www.example.com -d*.www.example.com

All issued certificates will be stored in ~/.acme.sh/<example.com>/.

That’s it, for more official support please refer to https://zerossl.com/documentation/acme/.

Install the certs

After the cert is generated, files are stored in ~/.acme.sh/<example.com>/, but it’s NOT recommended to use the certs file in the ~/.acme.sh/ folder, the folder structure may change in the future.

You’d better copy the certs to the target location, or you can use the following commands to copy the certs:

Nginx example:

acme.sh --install-cert-d example.com \--key-file       /path/to/keyfile/in/nginx/key.pem  \--fullchain-file /path/to/fullchain/nginx/fullchain.pem \--reloadcmd"sudo systemctl restart nginx"

Apache example:

acme.sh --install-cert-d example.com \--cert-file      /path/to/certfile/in/apache/cert.pem  \--key-file       /path/to/keyfile/in/apache/key.pem  \--fullchain-file /path/to/fullchain/certfile/apache/fullchain.pem \--reloadcmd"sudo service apache2 force-reload"

The ownership and permission info of existing files are preserved. You can pre-create the files to define the ownership and permissions.

The cert will be renewed every 60 days by default. Once the cert is renewed, the Apache/Nginx service will be reloaded automatically by the --reloadcmd command.

Please take care: The reloadcmd is very important. The cert can be automatically renewed, but, without a correct ‘reloadcmd’ the cert may not be flushed to your server(like nginx or apache), then your website will not be able to show renewed cert in 60 days.

Renew the certs

Indeed, you don’t need to renew the certs manually, all the certs will be renewed automatically every 60 days.

However, you can also force to renew a cert by:

acme.sh --renew-d example.com --force

Stop cert renewal

To stop the automatic renewal of a cert, you can execute the following to remove the cert from the renewal list:

acme.sh --remove-d example.com

The cert or key files are not removed from the local file system, you can remove these files or respective directories (e.g. ~/.acme.sh/example.com) by yourself.

❌