最近在给一套边缘数据同步方案做安全改造,用的核心组件是libSQL。libSQL是SQLite的兼容分支,保留单文件数据库的轻量特性,同时补齐了网络访问、副本同步和权限控制这些SQLite原生没有的能力。当时我们的使用场景很简单:几个边缘节点要把业务数据回传到中心数据平台,中间走公网。一开始想着数据量不大,压缩一下传输就行,但安全评审直接否决了——明文传输意味着任何人只要在链路上抓包,就能把整张表读走。于是把TLS加密和JWT认证这两块完整配了一轮,实测下来稳定运行,也踩了不少坑。这篇就把配置过程完整复盘一遍,给同样打算用libSQL做公网数据传输的团队做个参考。
这套方案适合谁?如果你的场景和我类似——自建libSQL服务端供多个客户端远程访问、公网或半信任网络传输、需要区分不同客户端的访问权限——那下面的内容可以直接对接。如果你是纯本机用SQLite,那还不涉及网络层安全问题,看完了解个思路就行。但只要你把数据库服务暴露到网络上,TLS和JWT就属于“必须配”而不是“建议配”的范畴。
1. 整体方案设计:为什么是TLS加JWT,而不是“加个密码就行”
1.1 从裸跑开始踩出来的风险清单
先说我最初的配置有多天真。libSQL服务端默认监听本地端口,连接走明文HTTP协议,没有任何身份校验。本机开发时没什么问题,但它一旦通过网络被远程访问,风险立刻变成现实:
- 明文协议意味着所有SQL语句、查询结果、甚至连接串里的信息都在网络上裸奔。抓包工具只要在链路任何一点启动捕获,就能完整还原数据内容。
- 没有认证机制,等于任何能访问到这个端口的人都可以执行任意SQL操作。如果你的服务端监听在0.0.0.0而不是127.0.0.1,那后果就是整个网络里的机器都能对你的数据库为所欲为。
- 即便你只在受信内网使用,内网也不是绝对安全。我在一次内部演练中,用一个简单的端口扫描脚本,几分钟就发现了某个测试环境上未加密的数据库端口,数据毫无防护。
这个风险清单让我意识到,所谓的“临时先用着”,实际上是在给数据埋雷。
1.2 TLS和JWT的职责划分,缺一个都不行
安全改造中最容易犯的错,是“以为做了加密就等于安全”。加密解决的是传输层面的问题,认证解决的是权限层面的问题,两者职责不同,必须同时落地。
- TLS负责传输安全。它保证客户端和服务端之间的通信经过加密,中间人无法窃听或篡改内容。TLS解决的是“数据在路上的安全”。
- JWT负责访问控制。它让服务端能识别“发起请求的客户端是谁”,以及“这个客户端有没有权限执行这个操作”。JWT解决的是“谁能进数据库”的问题。
只配TLS不配JWT,数据库就变成一把“谁都能开门但路上偷不走”的锁,任何能连上服务端的人依然可以读写数据。只配JWT不配TLS,token在网络上明文传输,攻击者截获token后直接伪造请求,认证形同虚设。只有两个都到位,链路加密和身份校验互相补位,才算形成闭环。
当时我也纠结过另一个方案:用HTTP Basic Auth加IP白名单行不行。Basic Auth确实简单,但它要求每次请求都携带用户名密码,密码一旦泄露就是灾难;IP白名单在边缘节点IP频繁变化时维护成本极高,而且伪造来源IP并不是什么高门槛的事。JWT通过短期有效的签名token,把凭据泄露的风险窗口压缩到exp过期时间之内,明显更适合多客户端场景。
2. 环境准备与证书体系搭建
2.1 工具链和版本选择
开始动手之前,建议先把工具链理清楚。我这里用的是:
- libSQL服务端(sqld),选的是当前稳定发布版。不同版本的启动参数略有差异,强烈建议先用
sqld --help确认当前版本支持哪些TLS和JWT相关参数。 - OpenSSL 3.x,用来签发证书和处理密钥。版本差异主要影响默认加密算法,3.x默认对齐现代安全标准,足够用了。
- Python 3.10以上,加上PyJWT库,用于生成和验证JWT签名。如果用Go、Node或Rust做客户端,也有对应的JWT库,原理完全相同。
- curl和openssl s_client,用来实测TLS是否真正生效。
关于libSQL服务端的具体版本,我建议不要盲目追新,选一个稳定版本后,先在内网环境把全流程跑通,再考虑升级。
2.2 动手签发自己的CA和服务器证书
libSQL服务端启用TLS,核心是给它配一对证书和私钥。生产环境如果已经有公司内部的CA体系,直接用内部CA签发就行。我们当时没有现成CA,所以自己搭了一套内部CA,用同一把CA签服务器证书,客户端再信任这个CA,证书链就完整了。
先创建CA私钥和CA根证书:
# 生成CA私钥 openssl genrsa -out ca.key 4096 # 生成CA根证书,有效期给到10年 openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 \ -out ca.crt -subj "/CN=LibSQL Internal CA"CA私钥是整套体系的信任锚点,务必保管好。我见过有人把CA私钥直接放在服务器目录里,一旦泄露,攻击者就可以给任意域名签发证书,整个信任体系崩塌。建议CA私钥只在离线环境用,颁发证书时才临时挂载进来,用完后移走。
然后生成服务器证书。这里有个关键点:现代TLS校验都会检查证书里的SAN(主题备用名),也就是证书声明的域名和IP列表。如果你的服务器证书SAN里没有客户端访问时用的域名或IP,即使证书其他部分全部正确,TLS握手也会失败。我第一次配置时只写CommonName,没写SAN,结果客户端一直报证书校验失败,排查了半个小时才反应过来。
生成服务器私钥和CSR:
# 生成服务器私钥 openssl genrsa -out server.key 2048 # 生成证书签名请求 openssl req -new -key server.key -out server.csr \ -subj "/CN=libsql-server"然后写一个SAN扩展文件,把客户端会访问的域名和IP都写进去:
cat > san.cnf <<EOF [req] req_extensions = v3_req [v3_req] subjectAltName = @alt_names [alt_names] IP.1 = 127.0.0.1 DNS.1 = libsql-server DNS.2 = localhost EOF最后用CA签发服务器证书:
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial \ -out server.crt -days 825 -sha256 \ -extensions v3_req -extfile san.cnf有效期故意设置了825天,约等于两年多一点。建议证书有效期不要设太长,一年到两年比较平衡。设太长,一旦私钥泄露要紧急吊销,牵扯面大;设太短,频繁换证书容易在客户端遗漏。825天是我试过的一个比较舒服的周期,跨过两个年度审计,又不会短到让人手忙脚乱。
2.3 证书续期和轮换的简单思路
证书即将到期时,不需要重新搭建CA,只需要用同一个CA再签一张新服务器证书。做法和首次签发完全一样,然后替换服务端的证书文件,再让sqld重新加载证书。大部分场景下重启服务即可。客户端只要继续信任同一个CA,就不需要改动任何连接配置。
我这里实际维护了一套很小的证书轮换脚本,核心逻辑就三步:备份旧证书、复制新证书到固定路径、触发服务端重载。脚本本身不复杂,但固定下来之后省了很多麻烦。建议任何线上服务都做这一层自动化,别拿“记在日历上手动换”当真。
3. TLS加密完整配置与客户端验证
3.1 服务端启用TLS的参数配置
sqld支持通过启动参数直接配置TLS。我实际用的启动命令大致如下:
sqld \ --http-listen-addr 0.0.0.0:8000 \ --tls \ --tls-cert /etc/libsql/server.crt \ --tls-private-key /etc/libsql/server.key这里几个参数的含义分别是:
--http-listen-addr指定监听地址和端口。0.0.0.0表示服务端监听所有网络接口,这样远程客户端才能连进来。如果只在单机测试,用127.0.0.1更安全。--tls开启TLS加密,让libSQL的HTTP端点从明文切换到HTTPS/WSS。--tls-cert指向服务器证书文件。--tls-private-key指向服务器私钥文件,注意私钥文件权限尽量收紧,比如设为600,只允许运行用户读取。
启动后观察日志,确认服务端没有报证书相关错误。我遇到过一次私钥和证书不匹配的情况,sqld启动后日志直接提示TLS初始化失败,排查发现是私钥换过一版,证书却还是旧签发的,两者不搭。检查私钥和证书是否匹配的快速命令是这样的:
# 比较证书公钥和私钥的modulus是否一致 openssl x509 -in server.crt -noout -modulus | openssl sha256 openssl rsa -in server.key -noout -modulus | openssl sha256两条命令输出相同,说明匹配;不同,则说明证书和私钥不是一家人。
3.2 客户端连接串与CA信任配置
服务端TLS只是第一步,客户端这边如果不做任何改动,直接用libsql://协议连接,会遇到证书校验失败。这里要理解一个核心机制:客户端必须明确信任签发了服务端证书的那把CA,否则TLS握手会在校验环节被拒绝。
libSQL客户端连接串支持直接指定CA证书路径,格式大致如下:
libsql://libsql-server:8000?tls=1&ca=/etc/ssl/certs/libsql-ca.crt参数解释:
libsql://前缀本身代表走HTTPS/WSS,也就是TLS加密连接。tls=1显式要求启用TLS。在部分客户端实现中,不加这个参数可能默认尝试非加密连接。ca=指向CA证书文件,客户端会用这个CA去校验服务端证书的签名是否可信。
不同语言SDK的写法略有差异,但核心配置项都是ca证书路径。我给Java客户端、Node客户端、Python客户端都配过一遍,底层无非都是把CA证书加载进TLS的信任存储。README里通常写得很清楚。
这里有个初学者常见的错误:为了省事,在客户端设置里把TLS校验关掉,比如tls=0或者SDK里的“跳过证书验证”选项。本地调试可以理解,但一旦放到生产,这就是把TLS的作用清零了。关闭校验意味着任何中间人都可以用自签证书冒充你的服务端,加密形同虚设。我的原则很简单:本地调试可以临时跳过校验,但任何通向真实数据的连接,必须正常校验证书。
3.3 用openssl实测TLS是否真正生效
配置完以后,别急着写业务代码,先用openssl s_client实地验一次握手,确认TLS链路上的证书、CA和加密套件都正常。命令如下:
openssl s_client -connect libsql-server:8000 -CAfile /etc/libsql/ca.crt -servername libsql-server如果握手成功,输出里能看到一串证书链信息,以及一段类似Verify return code: 0 (ok)的内容。看到0 (ok)说明证书校验通过。如果看到的是unable to get local issuer certificate或certificate verify failed,那就要回头检查CA信任和SAN配置了。
我还习惯用curl再验证一次HTTP层:
curl -v https://libsql-server:8000/ --cacert /etc/libsql/ca.crtcurl的-v输出里能看到SSL connection using TLSv1.3这类信息。只要确认版本是TLSv1.2或TLSv1.3,并且没有证书告警,基本可以认为TLS配置到位了。
4. JWT认证的签发机制与libSQL接入
4.1 认证体系选型:为什么用RS256而不是HS256
JWT的签名算法有很多种,最常用的是HS256和RS256。我推荐RS256,核心原因在于密钥的职责分离。
- HS256是对称签名,签名和验证用的是同一个密钥。这意味着所有需要验证JWT的服务端都必须持有这把密钥,一旦密钥泄露,攻击者可以自己签发任意身份的token。
- RS256是非对称签名,私钥负责签发,公钥负责验证。私钥只需要保存在签发电——通常是认证服务那里,而libSQL服务端只需要持有公钥,就能验证token。即使公钥泄露,攻击者也伪造不了token。
在我们的场景里,客户端token统一由中心认证服务签发,边缘节点的libSQL服务端只负责验证。用RS256顺理成章:边缘节点即使被物理接管,攻击者拿到的也只是一把公钥,签发不了新token,整个体系还能撑得住。
4.2 生成JWT专用的密钥对
专门为JWT生成一对RSA密钥,不用复用TLS的服务器证书密钥。两边加密用途和生命周期管理都不同,放在一起容易在轮换时互相影响。命令如下:
# 生成JWT专用私钥 openssl genrsa -out jwt_private.pem 2048 # 从私钥导出公钥 openssl rsa -in jwt_private.pem -pubout -out jwt_public.pemjwt_private.pem放中心认证服务,负责签发token;jwt_public.pem放到libSQL服务端,负责验证token。两者路径要分开保管,权限都设置成最小可读。
4.3 服务端开启JWT认证校验
sqld支持通过参数开启JWT验证。我配置时的启动命令在原有TLS参数基础上,增加了JWT相关的参数:
sqld \ --http-listen-addr 0.0.0.0:8000 \ --tls \ --tls-cert /etc/libsql/server.crt \ --tls-private-key /etc/libsql/server.key \ --auth-jwt-key-file /etc/libsql/jwt_public.pem \ --auth-jwt-issuer libsql-edge关键参数说明:
--auth-jwt-key-file指向JWT公钥文件,服务端用这把公钥验证token签名。--auth-jwt-issuer设置期望的签发者。客户端token里的iss声明必须和这个值一致,否则校验失败。
服务端开启JWT验证后,所有访问数据库的HTTP请求都必须携带有效的Authorization头,格式是Authorization: Bearer <token>。未携带token或token无效的请求,会直接收到401拒绝。这一点可以在配置后立刻用一个不带token的请求验证:
curl -i https://libsql-server:8000/预期返回401。如果返回200,说明JWT验证没有生效,先别继续往下,回头查参数是否真的被加载。
4.4 客户端签发token和实际请求流程
有了JWT密钥对,就能签发客户端token了。用Python加PyJWT可以快速实现。代码如下:
import time import jwt # 读取JWT私钥 private_key = open("jwt_private.pem").read() now = int(time.time()) payload = { "iss": "libsql-edge", # 签发者,必须和服务端的 --auth-jwt-issuer 一致 "sub": "edge-node-01", # 主体,表示这个token归属于哪个客户端 "aud": "libsql-server", # 接收方,可选项,但建议加上 "iat": now, # 签发时间 "exp": now + 3600, # 过期时间,单位秒,这里设为1小时后过期 "role": "writer" # 自定义声明,可以用于权限细分 } token = jwt.encode(payload, private_key, algorithm="RS256") print(token)这段代码生成一个1小时有效的token。拿到token后,用curl访问libSQL服务端的HTTP端点:
curl -v https://libsql-server:8000/ \ --cacert /etc/libsql/ca.crt \ -H "Authorization: Bearer $TOKEN"正常响应不会返回401,而是返回数据库服务的HTTP响应。我当初验证到这一步时,特意把token里的exp改成过去时间,再请求一次,立刻被401拒绝,说明过期校验是生效的。
关于JWT的过期时间,建议不要太长。内部系统之间做同步,token有效期设15分钟到1小时都可以,过期后客户端重新向认证服务申请新token。如果设成7天、30天,泄露风险窗口太大,违背了JWT短时效设计的初衷。
5. 联调实录、问题排查与安全加固建议
5.1 联调检查清单:配置完先过一遍这几项
全链路配置完以后,我给自己列了一个检查清单,每次部署完都会按这个顺序过一遍:
- 用
openssl s_client验证TLS握手是否成功,证书链是否被CA信任。 - 不带token请求数据库端口,确认返回401。
- 带一个签名字段乱写的假token请求,确认返回401,而不是别的错误码。
- 带一个正确签名但iss不符的token请求,确认返回401。
- 带一个正确签名、iss符合且未过期的token请求,确认返回200。
- 检查服务端日志,确认没有证书加载或公钥加载的警告。
这份清单里的前五项覆盖了TLS和JWT的双向验证:TLS验证证书链路,JWT验证签名、签发者、时效性三个关键维度。哪一步不对,直接定位到对应环节。
5.2 常见问题和排查思路速查
配置过程中我遇到的典型问题,以及对应的排查方向,整理如下。
TLS握手失败,报错unable to get local issuer certificate
原因几乎都在客户端没有正确加载CA证书。检查客户端连接串里ca参数是否指向CA文件,以及CA文件内容是否为根证书而不是服务器证书。
TLS握手失败,报错Hostname mismatch
服务端证书的SAN里没有包含客户端访问时用的域名或IP。回到生成CSR那一步,把SAN里的DNS.1和IP.1补齐,重新签发证书。
私钥和证书不匹配,服务端启动直接报错
用modulus校验命令确认server.crt和server.key是否配对。不配对就重新签发,别硬试。
带token请求仍然返回401
逐个排查token的签名、iss、exp、iat。签名错误通常是客户端用了错误的私钥;iss错误是签发payload时设置的iss和服务端--auth-jwt-issuer不一致;exp过期或iat在未来则是系统时钟偏差导致。特别是iat在未来,常出现在时钟没有同步的机器上,建议在服务器上开启时间同步服务,避免这类低级问题。
配置了JWT但未带token的请求也返回200
多半是sqld启动时没有真正加载JWT参数。用sqld --help确认当前版本参数名是否一致,再看启动日志里有没有对应加载记录。
连接串已经带了ca参数,客户端仍在报SSL错误
少数SDK除了ca参数,还要求显式设置TLS模式。检查连接串里tls=1,部分实现里libsql://协议默认就开启TLS,但如果有自定义非标准端口,需要确认协议前缀是否正确。
5.3 几个值得注意的加固细节
**CA私钥和JWT私钥都要独立保管。**CA私钥泄露等于整个信任体系崩盘;JWT私钥泄露等于任何人都能签发合法身份。我的习惯是CA私钥离线保存,JWT私钥存放在中心认证服务的独立密钥目录,权限600,禁止普通账号读取。
**日志审计不能省。**sqld本身的请求日志我建议保留,至少保留HTTP状态码、来源IP和请求时间。出问题时要能倒推出“某个IP在什么时间点拿着谁的token做了什么操作”。这个在排障时非常有用。
**客户端SDK版本和服务端版本尽量保持一致。**我踩过一次坑,旧版客户端对TLS证书链校验的默认行为不同,导致在服务端升级TLS配置后,老客户端不认。后来统一升级客户端SDK版本,问题消失。
**JWT的密钥轮换要安排上。**RSA密钥对不需要频繁换,但半年到一年轮换一次是合理节奏。轮换时有两个方法:一是直接替换公钥文件,让服务端加载新公钥,同时所有客户端换成新私钥签发的token;二是在认证服务端保留多把私钥,签发时选一把,libSQL服务端持有对应的多把公钥,过渡期新旧token都能验证。第二种在客户端数量大时更平滑。
经过这轮改造,我们的边缘节点到中心数据平台之间的数据传输,链路是加密的,身份是验证过的,任何无token或伪造token的请求都被挡在外面。之后又在另一套测试环境上完整复现了一遍配置流程,验证了这套方案的可复制性。我自己实际操作下来的体会是:TLS和JWT的配置本身并不复杂,真正花时间的是把证书体系、密钥保管、轮换流程和排障清单这些“地基工程”搭扎实。安全配置最怕的就是“配完就忘”——证书会过期,密钥要轮换,客户端版本在变。把检查清单和续期流程制度化,才是让这套防护长期有效的关键。
最后顺手分享一个排查小技巧:如果客户端报TLS错误,先用openssl s_client本地连一遍服务端,把所有输出保存下来;再看客户端SDK的详细日志。绝大多数TLS握手问题,通过对比这两份信息就能秒定位。这套联调方法在我后续其他项目里也一直在用。