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

2026-07-25 15:43:43 +0800 CST views 6

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

妙处在哪?窃听者看到的 XY 里,口令的贡献 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 协议文档。

推荐文章

Web浏览器的定时器问题思考
2024-11-18 22:19:55 +0800 CST
Vue3中如何进行错误处理?
2024-11-18 05:17:47 +0800 CST
前端代码规范 - Commit 提交规范
2024-11-18 10:18:08 +0800 CST
如何实现虚拟滚动
2024-11-18 20:50:47 +0800 CST
程序员茄子在线接单