编程 croc 深度解剖:一条命令跨机器传文件——PAKE 密钥协商、Relay 中继架构与 Go 工程实现全解析

2026-07-25 15:43:43

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 生态部署麻烦,速度也一般。

这些方案共同的痛点可以归纳为三个:

  1. 网络可达性:NAT、防火墙把点对点直连的路堵死了;
  2. 安全性:明文传输或依赖第三方云端存储;
  3. 易用性:交换 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 比单条快?两个原因:

  1. 单流 TCP 受拥塞窗口和 RTT 制约:吞吐上限 ≈ 窗口大小 / RTT。跨国链路 RTT 200ms 时,单流很难跑满带宽;四条流等于四倍拥塞窗口。
  2. 丢包恢复隔离:一条流丢包进入快速重传/慢启动时,其他流不受影响,整体吞吐更平稳。

这与 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

两个结论:

  1. 内网场景 croc 甚至比 scp 快——scp 的单流 + SSH 加密协议开销是瓶颈,croc 的多流并行占了便宜;
  2. 公网场景 relay 位置就是一切。公共 relay 是共享资源且可能绕远路;自建同城 relay 后吞吐可提升数倍。团队用 croc,自建 relay 不是可选项而是必选项。

5.3 何时不该用 croc

工具要用在刀刃上,这些场景请换方案:

  • 定时批量同步:rsync + cron / rclone 才是正解,croc 是交互式工具;
  • 一对多分发:croc 是一对一模型,分发给几十台机器请用对象存储或 P2P 分发(如 dragonfly);
  • 极端大文件(数百 GB)且双方可直连:直接 rsync,省掉 relay 转发的双倍带宽消耗;
  • 不允许任何第三方节点经手的合规环境:虽然 relay 只见密文,但某些合规条款连密文经手都不允许,此时走内网专线工具。

六、横向对比:croc vs magic-wormhole vs 传统工具

维度crocmagic-wormholescp/rsync网盘
语言/部署Go 单二进制Python,依赖多系统自带客户端
NAT 穿透relay 转发,必通relay/打洞需可达云中转
加密模型PAKE + ChaCha20PAKE(SPAKE2) + NaClSSH服务商可见
断点续传✅❌rsync ✅✅
多流并行✅❌❌看客户端
文件夹传输✅ 原生需打包✅✅
自建中继✅ 一条命令✅ 较繁琐-❌
口令 UX单词口令,可口述单词口令密钥管理账号体系

croc 与 magic-wormhole 是同一思想的两个实现(croc 作者也明确致敬过 wormhole),croc 胜在 Go 生态带来的部署便利、多流传输性能和断点续传。可以说 croc 是"wormhole 思想的工程完全体"。

七、总结与展望

croc 值得每个工程师研究,不只因为它好用,更因为它是一个小而完整的分布式系统安全设计范本:

  1. PAKE 是被低估的密码学原语。 "弱口令协商强密钥、免疫离线爆破"这个能力,在设备配对、局域网服务认证、IoT 首次配网等场景都有巨大应用空间,而大多数开发者甚至不知道它的存在。
  2. 端到端加密解放了架构。 因为中继不可信也无害,croc 得以放弃复杂的 NAT 打洞,选择最朴素的 TCP 转发拓扑。安全设计做对了,架构反而变简单——这个因果关系值得反复咀嚼。
  3. UX 是安全工具的一等公民。 可口述的单词口令、自动局域网发现、免配置单二进制——croc 流行的真正原因是它把安全做成了"零感知"。安全工具卷易用性,才是正确的内卷方向。

展望后续,croc 社区正在讨论的方向包括 QUIC 传输层(0-RTT 重连 + 内建多流,天然契合 croc 的并行模型)、后量子 PAKE(应对量子计算对椭圆曲线的威胁)以及浏览器 WebRTC 端。无论这些落地与否,"一条命令、一个口令、端到端加密"的交互范式已经证明了自己——它大概率会成为未来所有点对点工具的标配。

最后留一个动手作业:把第四节的 mini-croc 补全接收端并加上多流并行,你会对"安全信道之上一切皆简单"有更深的体感。


参考:schollz/croc(GitHub)、schollz/pake、SPAKE2 RFC 草案、magic-wormhole 协议文档。

推荐文章

程序员茄子在线接单