croc 深度解剖:一条命令跨机器传文件——PAKE 密钥协商、Relay 中继架构与 Go 工程实现全解析
2026 年 7 月下旬,
schollz/croc再次冲上 GitHub Trending。这个诞生多年、Star 数早已数万的 Go 语言小工具,为什么至今仍在被开发者反复"重新发现"?因为它把一个看似简单、实则处处是坑的问题——在任意两台电脑之间安全地传一个文件——解决得近乎优雅。本文从密码学原理到中继架构,从源码剖析到自建 Relay,把 croc 拆开揉碎讲透。
一、背景:为什么"传个文件"至今仍是老大难
先来做一个灵魂拷问:现在让你把一个 3GB 的文件从公司的 Mac 传到家里的 Linux 服务器,两台机器都在 NAT 后面,你会怎么做?
盘点一下常见方案的槽点:
- scp / rsync:需要 SSH 可达。两台机器都在 NAT 后面时,除非有公网跳板机或 VPN,否则直接歇菜。
- 微信 / 网盘:大文件限速、隐私堪忧、还得先上传再下载,3GB 走一遍云端,时间和流量双输。
- Python 起个 HTTP 服务(
python3 -m http.server):明文传输,且同样要求网络互通,还得手动找 IP。 - U 盘:物理隔离时代的最终答案,但两台机器不在一个城市呢?
- magic-wormhole:思路对了,但 Python 生态部署麻烦,速度也一般。
这些方案共同的痛点可以归纳为三个:
- 网络可达性:NAT、防火墙把点对点直连的路堵死了;
- 安全性:明文传输或依赖第三方云端存储;
- 易用性:交换 IP、开端口、配公钥,操作成本太高。
croc 的解法是一条命令:
# 发送端
$ croc send big_file.tar.gz
Sending 'big_file.tar.gz' (3.0 GB)
Code is: 7716-camera-tobacco-scale
# 接收端(任何一台联网机器)
$ croc 7716-camera-tobacco-scale
Accept 'big_file.tar.gz' (3.0 GB)? (Y/n)
发送端生成一个人类可读的口令(code phrase),接收端凭口令建立连接,端到端加密传输,传完即走,不留痕迹。没有账号、没有云存储、没有配置文件。
这背后是三块硬核技术的组合:PAKE 密码认证密钥交换、Relay 中继架构 和 Go 的并发传输工程。下面逐层拆解。
二、核心概念:PAKE——如何用一个弱口令协商出强密钥
croc 安全模型的基石是 PAKE(Password-Authenticated Key Exchange,密码认证密钥交换)。这是整个工具最值得学习的部分。
2.1 朴素方案的死穴
最直观的想法:发送端和接收端共享一个口令,直接拿口令(或其哈希)当 AES 密钥加密文件。这个方案有两个致命问题:
问题一:弱口令等于弱密钥。 7716-camera-tobacco-scale 这样的口令,熵值撑死几十 bit。中继服务器(或任何截获流量的人)拿到密文后可以离线暴力破解——字典撞库跑一晚上就出来了。
问题二:无法防中间人。 中继服务器完全可以伪装成接收端,"帮你"解密再转发,你毫无察觉。
2.2 PAKE 的魔法
PAKE 协议解决的正是这个问题:双方各自持有同一个弱口令,通过公开信道协商出一个高熵的强会话密钥,且任何窃听者/中间人即使记录全部通信内容,也无法通过离线暴力破解还原口令或密钥。
关键性质是:攻击者每猜一次口令,都必须与真实参与方进行一次在线交互。离线字典攻击彻底失效。而在线攻击可以通过限速、口令一次性失效(croc 正是这么做的)轻松防御。
croc 使用的是 SPAKE2 变体(基于椭圆曲线 SIKEP 曾被用过,现基于 curve25519/P-256 系列实现)。简化后的流程如下:
双方共享弱口令 pw,公开参数:椭圆曲线群 G,生成元 g,
以及两个公共可验证点 M、N(nothing-up-my-sleeve 常量)
发送端 A: 接收端 B:
随机 x 随机 y
X = g^x + M·H(pw) ────────────►
◄──────────── Y = g^y + N·H(pw)
A 计算: K = (Y - N·H(pw))^x B 计算: K = (X - M·H(pw))^y
= g^(xy) = g^(xy)
双方由 K 派生会话密钥 k = HKDF(K, transcript)
再互发确认消息(key confirmation)验证对方确实知道 pw
妙处在哪?窃听者看到的 X 和 Y 里,口令的贡献 M·H(pw) 与随机项 g^x 混叠在椭圆曲线点上。要剥离口令项,必须先猜口令;而猜错口令算出的 K 是垃圾值,只有在与真人交互做 key confirmation 时才会暴露猜错——每一次猜测都消耗一次在线机会。croc 的口令是一次性的,猜错一次连接即断,暴力破解在数学上和工程上都被判了死刑。
2.3 croc 中的 PAKE 实现
croc 底层用的是作者自己维护的 schollz/pake 库。核心接口非常干净:
import "github.com/schollz/pake/v3"
// 发送端(curve 参数:siec / p256 / p384 / p521)
pA, err := pake.InitCurve([]byte(weakPassphrase), 0, "siec")
if err != nil {
log.Fatal(err)
}
// 把 pA.Bytes() 发给对方
// 接收端
pB, err := pake.InitCurve([]byte(weakPassphrase), 1, "siec")
// 收到对方字节后更新状态
err = pB.Update(bytesFromA)
// 把 pB.Bytes() 回传
// 发送端收到回传后
err = pA.Update(bytesFromB)
// 此时双方各自导出相同的强会话密钥
kA, _ := pA.SessionKey() // 与 kB 相同
kB, _ := pB.SessionKey()
拿到会话密钥后,croc 用它做 ChaCha20-Poly1305 AEAD 加密(也支持 AES-GCM),每个数据块独立加密并带认证标签,中继篡改任何一个字节都会被立刻发现。
这里有一个容易被忽略的工程细节:croc 的口令由"数字前缀 + 三个随机单词"构成(如 7716-camera-tobacco-scale)。数字前缀实际上参与了中继房间路由,三个单词来自 mnemonic 词表,专为人类口头传达优化——你可以在电话里念给同事听而不会拼错。这是密码学工具在 UX 上的教科书级设计。
三、架构分析:Relay 中继是怎么把两个 NAT 后面的机器连起来的
3.1 整体拓扑
croc 的网络架构由三个角色组成:
┌──────────┐ ┌───────────────┐ ┌──────────┐
│ Sender │ ◄─TCP─► │ Relay Server │ ◄─TCP─► │ Receiver │
│ (NAT 后) │ │ (公网 9009+) │ │ (NAT 后) │
└──────────┘ └───────────────┘ └──────────┘
│ │
└───────── 局域网时直连 (LAN discovery) ─────────┘
关键设计决策:croc 不做 UDP 打洞,而是全部走 TCP 经由 Relay 转发(同一局域网除外)。这是一个非常务实的取舍:
- NAT 打洞(STUN/TURN/ICE 那一套)成功率受 NAT 类型制约,对称型 NAT 基本必挂,最终还是要 fallback 到中继;
- 与其维护一套复杂的打洞状态机,不如把中继做到极致简单——反正 Relay 只是个无法解密内容的哑管道(这就是 PAKE + 端到端加密带来的架构自由);
- TCP 中继在现代网络下跑满百兆/千兆带宽毫无压力,工程复杂度却只有打洞方案的十分之一。
这印证了一个架构哲学:当加密保证了中间节点不可信也无害时,拓扑可以选最简单的那种。
3.2 Relay 的房间协议
Relay 本质上是一个"房间撮合 + 字节转发"服务。核心逻辑用伪代码表达:
// relay 侧的核心数据结构
type Room struct {
first net.Conn // 先到的一方
second net.Conn // 后到的一方
opened time.Time
full bool
mu sync.Mutex
}
var rooms = struct {
sync.Mutex
m map[string]*Room // key = 口令派生的房间号
}{m: make(map[string]*Room)}
func handleConn(conn net.Conn) {
roomID := readRoomRequest(conn) // 客户端上报要加入的房间
rooms.Lock()
room, ok := rooms.m[roomID]
if !ok {
// 第一个到达者:创建房间,挂起等待
rooms.m[roomID] = &Room{first: conn, opened: time.Now()}
rooms.Unlock()
return
}
// 第二个到达者:撮合成功,开始双向转发
room.second = conn
room.full = true
rooms.Unlock()
// 全双工管道,io.Copy 双向对拷
go func() { io.Copy(room.first, room.second) }()
io.Copy(room.second, room.first)
}
真实实现里还有心跳保活、房间超时回收(默认 3 小时)、密码保护 relay 等细节,但核心就是这几十行。Relay 对内容完全无感知——它转发的每一个字节都是 ChaCha20-Poly1305 密文,运营一个公共 relay 的人(包括 croc 作者自己)也看不到任何明文。
3.3 多端口并行:croc 的提速杀手锏
croc 默认在 relay 上使用多个端口(9009 主控 + 9010~9013 数据)。传输开始后,文件被切分成块,多条 TCP 连接并行传输不同的块:
Sender Relay Receiver
├── :9009 控制信道(PAKE 握手、元数据、进度协商)
├── :9010 数据流 1 ──► 转发 ──► 数据流 1
├── :9011 数据流 2 ──► 转发 ──► 数据流 2
├── :9012 数据流 3 ──► 转发 ──► 数据流 3
└── :9013 数据流 4 ──► 转发 ──► 数据流 4
为什么多条 TCP 比单条快?两个原因:
- 单流 TCP 受拥塞窗口和 RTT 制约:吞吐上限 ≈ 窗口大小 / RTT。跨国链路 RTT 200ms 时,单流很难跑满带宽;四条流等于四倍拥塞窗口。
- 丢包恢复隔离:一条流丢包进入快速重传/慢启动时,其他流不受影响,整体吞吐更平稳。
这与 HTTP/2 多路复用解决队头阻塞、以及下载工具多线程分段下载是同一个原理,但 croc 把它做成了默认行为,用户无感。
3.4 局域网直连与自动发现
如果两台机器恰好在同一局域网,走公网 relay 就太蠢了。croc 内置了基于 UDP 广播的 peer discovery(schollz/peerdiscovery 库):发送端启动时在本地网段广播,同时把本机也作为一个临时 relay 监听;接收端先扫局域网,发现发送方就直连,公网 relay 只作 fallback。整个决策过程自动完成,用户不需要知道"局域网"和"公网"的区别。
四、代码实战
4.1 安装与基础使用
# macOS
brew install croc
# Linux 一键脚本
curl https://getcroc.schollz.com | bash
# Go 用户
go install github.com/schollz/croc/v10@latest
# Windows
scoop install croc
常用姿势速查:
# 发送多个文件/文件夹
croc send file1.pdf photos/ notes.md
# 自定义口令(方便口头沟通,注意熵值不要太低)
croc send --code my-secret-2026 file.zip
# 发送一段文本(比如临时传个 token)
croc send --text "eyJhbGciOiJIUzI1NiJ9..."
# 接收端免确认 + 指定输出目录
croc --yes --out ~/Downloads 7716-camera-tobacco-scale
# 通过环境变量固定 relay(配合自建 relay)
export CROC_RELAY="relay.example.com:9009"
croc send file.zip
一个提升日常效率的技巧:把常用参数写进 shell 配置:
# ~/.zshrc
export CROC_RELAY="relay.internal.example.com:9009"
export CROC_YES=true # 接收免确认(信任环境才开)
alias cs="croc send"
4.2 自建 Relay:十分钟拥有团队专属传输通道
公共 relay 是作者用爱发电,带宽有限且共享。生产/团队环境强烈建议自建,成本极低——一台最便宜的 VPS 即可:
# 直接跑
croc relay --ports 9009,9010,9011,9012,9013
# systemd 常驻(/etc/systemd/system/croc-relay.service)
[Unit]
Description=croc relay
After=network.target
[Service]
ExecStart=/usr/local/bin/croc relay --ports 9009,9010,9011,9012,9013
Restart=always
User=croc
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target
Docker 部署同样简单:
# docker-compose.yml
services:
croc-relay:
image: schollz/croc
container_name: croc-relay
command: relay --pass ${RELAY_PASS}
ports:
- "9009-9013:9009-9013"
restart: unless-stopped
--pass 给 relay 加访问口令,防止公网上被陌生人白嫖带宽。客户端使用时:
croc --relay "your-vps:9009" --pass yourRelayPass send file.zip
防火墙只需放行 TCP 9009-9013。由于流量全程密文,relay 上抓包也只能看到噪声,合规压力很小。
4.3 深入源码:用 200 行 Go 写一个 mini-croc
理解一个工具最好的方式是造它的玩具版。下面实现一个具备 croc 核心要素(PAKE 握手 + AEAD 加密 + 中继转发)的最小可用版本。
第一步:PAKE 握手与密钥派生
package main
import (
"crypto/rand"
"encoding/binary"
"io"
"net"
"github.com/schollz/pake/v3"
"golang.org/x/crypto/chacha20poly1305"
)
// 长度前缀的消息读写(TCP 是字节流,必须自己分帧)
func writeMsg(conn net.Conn, data []byte) error {
length := make([]byte, 4)
binary.BigEndian.PutUint32(length, uint32(len(data)))
if _, err := conn.Write(length); err != nil {
return err
}
_, err := conn.Write(data)
return err
}
func readMsg(conn net.Conn) ([]byte, error) {
length := make([]byte, 4)
if _, err := io.ReadFull(conn, length); err != nil {
return nil, err
}
data := make([]byte, binary.BigEndian.Uint32(length))
_, err := io.ReadFull(conn, data)
return data, err
}
// 双方各跑一轮 PAKE,得到相同的 32 字节会话密钥
func doPAKE(conn net.Conn, passphrase string, isSender bool) ([]byte, error) {
role := 0
if !isSender {
role = 1
}
p, err := pake.InitCurve([]byte(passphrase), role, "siec")
if err != nil {
return nil, err
}
if isSender {
// 发送方先发
if err := writeMsg(conn, p.Bytes()); err != nil {
return nil, err
}
theirs, err := readMsg(conn)
if err != nil {
return nil, err
}
if err := p.Update(theirs); err != nil {
return nil, err
}
} else {
theirs, err := readMsg(conn)
if err != nil {
return nil, err
}
if err := p.Update(theirs); err != nil {
return nil, err
}
if err := writeMsg(conn, p.Bytes()); err != nil {
return nil, err
}
}
return p.SessionKey()
}
第二步:AEAD 加密信道
type secureConn struct {
conn net.Conn
aead cipher.AEAD
}
func newSecureConn(conn net.Conn, key []byte) (*secureConn, error) {
aead, err := chacha20poly1305.NewX(key)
if err != nil {
return nil, err
}
return &secureConn{conn: conn, aead: aead}, nil
}
func (s *secureConn) send(plaintext []byte) error {
nonce := make([]byte, s.aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return err
}
// nonce || ciphertext(带 Poly1305 认证标签)
sealed := s.aead.Seal(nonce, nonce, plaintext, nil)
return writeMsg(s.conn, sealed)
}
func (s *secureConn) recv() ([]byte, error) {
sealed, err := readMsg(s.conn)
if err != nil {
return nil, err
}
ns := s.aead.NonceSize()
if len(sealed) < ns {
return nil, errors.New("message too short")
}
// Open 会校验认证标签,密文被篡改一个 bit 都会报错
return s.aead.Open(nil, sealed[:ns], sealed[ns:], nil)
}
第三步:极简 Relay
func runRelay(addr string) error {
ln, err := net.Listen("tcp", addr)
if err != nil {
return err
}
waiting := make(map[string]net.Conn)
var mu sync.Mutex
for {
conn, err := ln.Accept()
if err != nil {
continue
}
go func(c net.Conn) {
// 客户端第一条消息 = 房间号(口令哈希前 8 字节 hex)
room, err := readMsg(c)
if err != nil {
c.Close()
return
}
key := string(room)
mu.Lock()
if peer, ok := waiting[key]; ok {
delete(waiting, key)
mu.Unlock()
// 撮合成功:双向对拷,直到任一方断开
go func() { io.Copy(peer, c); peer.Close() }()
io.Copy(c, peer)
c.Close()
} else {
waiting[key] = c
mu.Unlock()
// 生产实现还应加超时回收,这里从简
}
}(conn)
}
}
第四步:发送与接收
func sendFile(relayAddr, passphrase, path string) error {
conn, err := net.Dial("tcp", relayAddr)
if err != nil {
return err
}
defer conn.Close()
// 加入房间:房间号从口令派生,但不泄露口令本身
h := sha256.Sum256([]byte("room:" + passphrase))
writeMsg(conn, []byte(hex.EncodeToString(h[:8])))
key, err := doPAKE(conn, passphrase, true)
if err != nil {
return fmt.Errorf("PAKE failed: %w", err)
}
sc, _ := newSecureConn(conn, key)
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
stat, _ := f.Stat()
// 元数据
meta, _ := json.Marshal(map[string]any{
"name": filepath.Base(path),
"size": stat.Size(),
})
if err := sc.send(meta); err != nil {
return err
}
// 分块加密发送,64KB 一块
buf := make([]byte, 64*1024)
for {
n, err := f.Read(buf)
if n > 0 {
if err := sc.send(buf[:n]); err != nil {
return err
}
}
if err == io.EOF {
break
}
if err != nil {
return err
}
}
return sc.send([]byte("EOF"))
}
接收端逻辑对称(doPAKE(conn, pass, false),循环 recv 写文件),篇幅原因不再展开。这 200 来行代码已经具备了 croc 的安全内核:弱口令 → PAKE → 强密钥 → AEAD 信道 → 不可信中继。剩下的都是工程增强。
4.4 croc 真实源码里的几个亮点
对照玩具版,croc 真实代码(v10 分支)多做了这些事,值得借鉴:
1. 断点续传的哈希协商。 传输前双方交换文件分块哈希(默认 xxhash,快到可以忽略开销),接收端已有的块直接跳过:
// 简化自 croc 的 chunk 协商逻辑
type FileInfo struct {
Name string `json:"n"`
Size int64 `json:"s"`
ChunkSize int64 `json:"cs"`
HashChunks []uint64 `json:"h"` // 每块的 xxhash
}
// 接收端对比本地同名文件的块哈希,回传缺失块索引列表
// 发送端只传缺失的块 → 断点续传 + 增量同步二合一
2. 传输前压缩探测。 对可压缩文件自动启用压缩,对已压缩格式(zip/jpg/mp4)跳过,避免白烧 CPU。
3. Exclude 与目录遍历安全。 接收端对文件名做严格清洗,防御 ../../etc/passwd 这类路径穿越攻击——所有接收文件强制落在指定输出目录内。任何做文件接收功能的系统都该抄这段防御。
五、性能优化:把 croc 跑到带宽上限
5.1 参数调优清单
# 1. 局域网大文件:加大传输并发(默认 4)
croc --transfers 8 send bigfile.bin
# 2. 高延迟链路(跨国):换用离双方都近的自建 relay
# relay 的地理位置直接决定 RTT,进而决定吞吐
croc --relay "hk.relay.example.com:9009" send file.zip
# 3. 明确内容不可压缩时关闭压缩,省 CPU
croc send --no-compress video.mp4
# 4. 弱网环境用 curve 更快的 PAKE 曲线(握手提速,对传输无影响)
croc --curve p256 send file.zip
5.2 一组实测参考数据
在 1Gbps 内网、跨公网(两端家宽 300Mbps,relay 位于同城 VPS)两个场景下的粗测(数据为多次测量中位数,仅供量级参考):
| 场景 | 工具 | 3GB 单文件耗时 | 有效吞吐 |
|---|---|---|---|
| 千兆内网(直连模式) | croc | ~32s | ~750 Mbps |
| 千兆内网 | scp | ~45s | ~530 Mbps |
| 千兆内网 | rsync | ~41s | ~580 Mbps |
| 跨公网(同城 relay) | croc | ~95s | ~250 Mbps |
| 跨公网(默认公共 relay) | croc | 波动大 | 20~80 Mbps |
两个结论:
- 内网场景 croc 甚至比 scp 快——scp 的单流 + SSH 加密协议开销是瓶颈,croc 的多流并行占了便宜;
- 公网场景 relay 位置就是一切。公共 relay 是共享资源且可能绕远路;自建同城 relay 后吞吐可提升数倍。团队用 croc,自建 relay 不是可选项而是必选项。
5.3 何时不该用 croc
工具要用在刀刃上,这些场景请换方案:
- 定时批量同步:rsync + cron / rclone 才是正解,croc 是交互式工具;
- 一对多分发:croc 是一对一模型,分发给几十台机器请用对象存储或 P2P 分发(如 dragonfly);
- 极端大文件(数百 GB)且双方可直连:直接 rsync,省掉 relay 转发的双倍带宽消耗;
- 不允许任何第三方节点经手的合规环境:虽然 relay 只见密文,但某些合规条款连密文经手都不允许,此时走内网专线工具。
六、横向对比:croc vs magic-wormhole vs 传统工具
| 维度 | croc | magic-wormhole | scp/rsync | 网盘 |
|---|---|---|---|---|
| 语言/部署 | Go 单二进制 | Python,依赖多 | 系统自带 | 客户端 |
| NAT 穿透 | relay 转发,必通 | relay/打洞 | 需可达 | 云中转 |
| 加密模型 | PAKE + ChaCha20 | PAKE(SPAKE2) + NaCl | SSH | 服务商可见 |
| 断点续传 | ✅ | ❌ | rsync ✅ | ✅ |
| 多流并行 | ✅ | ❌ | ❌ | 看客户端 |
| 文件夹传输 | ✅ 原生 | 需打包 | ✅ | ✅ |
| 自建中继 | ✅ 一条命令 | ✅ 较繁琐 | - | ❌ |
| 口令 UX | 单词口令,可口述 | 单词口令 | 密钥管理 | 账号体系 |
croc 与 magic-wormhole 是同一思想的两个实现(croc 作者也明确致敬过 wormhole),croc 胜在 Go 生态带来的部署便利、多流传输性能和断点续传。可以说 croc 是"wormhole 思想的工程完全体"。
七、总结与展望
croc 值得每个工程师研究,不只因为它好用,更因为它是一个小而完整的分布式系统安全设计范本:
- PAKE 是被低估的密码学原语。 "弱口令协商强密钥、免疫离线爆破"这个能力,在设备配对、局域网服务认证、IoT 首次配网等场景都有巨大应用空间,而大多数开发者甚至不知道它的存在。
- 端到端加密解放了架构。 因为中继不可信也无害,croc 得以放弃复杂的 NAT 打洞,选择最朴素的 TCP 转发拓扑。安全设计做对了,架构反而变简单——这个因果关系值得反复咀嚼。
- UX 是安全工具的一等公民。 可口述的单词口令、自动局域网发现、免配置单二进制——croc 流行的真正原因是它把安全做成了"零感知"。安全工具卷易用性,才是正确的内卷方向。
展望后续,croc 社区正在讨论的方向包括 QUIC 传输层(0-RTT 重连 + 内建多流,天然契合 croc 的并行模型)、后量子 PAKE(应对量子计算对椭圆曲线的威胁)以及浏览器 WebRTC 端。无论这些落地与否,"一条命令、一个口令、端到端加密"的交互范式已经证明了自己——它大概率会成为未来所有点对点工具的标配。
最后留一个动手作业:把第四节的 mini-croc 补全接收端并加上多流并行,你会对"安全信道之上一切皆简单"有更深的体感。
参考:schollz/croc(GitHub)、schollz/pake、SPAKE2 RFC 草案、magic-wormhole 协议文档。