Rowel 线上协议规范

September 1, 2026 · View on GitHub

本文档精确到字节。目标是:一个人(或一个模型)只读这份文档,就能写出与现有实现互通的第三方客户端,不需要读源码。

规范性用词:必须(MUST)、禁止(MUST NOT)、应当(SHOULD)、可以(MAY)。

一致性检验的唯一权威是 protocol/scripts/emit-vectors.js 生成的测试向量(ios/RowelTests/Fixtures/protocol-vectors.json)。本文档与向量冲突时,以向量为准,并且这属于文档 bug,须修正。

  • 术语与总览:§1
  • 配对载荷:§2
  • Noise 通道:§3
  • 隧道帧:§4
  • Relay 线上格式:§5
  • Relay HTTP 接口:§6
  • 直连接口:§7
  • 错误码:§8
  • 测试向量:§9

1. 术语与总览

术语含义
App发起方(Noise initiator)。手机。
Bridle响应方(Noise responder)。与 agent 同机。
Relay内容盲交换机。只按 circuit 转发不透明字节。
carrier承载 Noise 消息的传输。当前有两种:Relay WebSocket、局域网直连 WebSocket。
circuitRelay 上一条 App↔Bridle 的通路,u32 标识。
tunnel frameNoise 密文内部的应用层帧,JSON。

三层嵌套,从外到内:

WebSocket 二进制消息
  └─ [Relay 路径] mux 帧:u8 type | u32 circuit | payload      ← 仅 Bridle↔Relay 段
       └─ Noise 消息(握手消息 或 传输密文)
            └─ 隧道帧(JSON, UTF-8)

直连路径没有 mux 层:WebSocket 消息直接就是 Noise 消息。

所有多字节整数必须为大端序,除 Noise nonce 外(§3.4)。


2. 配对载荷

2.1 结构

配对码承载一个 JSON 对象。字段顺序必须如下(JSON.stringify 的插入顺序;向量逐字节比对):

字段类型必需含义
vnumber载荷版本。当前恒为 1
relaystringRelay 基址,如 wss://rowel.novabox.ai
directstring[]直连候选,ws://host:port最优在前。字段为 undefined 时整个键省略。
devicestring设备 id,见 §2.3。
keystringBridle 的 X25519 静态公钥,32 字节,base64url 无填充。
tokenstring一次性配对令牌,base64url 无填充。已配对设备重连时为空串。
namestring机器显示名。

序列化规则:

  • 必须为紧凑 JSON(无空格、无换行)
  • 禁止转义斜杠(/ 原样输出,不写 \/
  • 顶层键顺序必须为上表顺序

实现注记:Foundation 的 JSONEncoder 用字典承载 keyed container,不保证键顺序,且顺序在不同进程间不稳定。Swift 侧因此不能用 Codable 合成编码,须显式声明顺序。

2.2 深链

rowel://pair#<base64url(JSON)>

载荷在 fragment 里,因此即使被粘进浏览器也不会发给任何服务器。

解码方必须

  1. 校验前缀为 rowel://pair
  2. 取第一个 # 之后的全部内容
  3. base64url 解码(接受无填充;解码前按需补 =
  4. JSON 解析
  5. 校验 relaydevicetoken 非空,且 key 解码后恰为 32 字节

任一步失败必须拒绝,禁止部分接受。

2.3 设备 id

device = base64url( sha256("rowel-device" ‖ ed25519_signing_public_key)[0..16] )

取 SHA-256 前 16 字节,base64url 无填充(22 字符)。

注意:派生自 Ed25519 签名公钥,不是 X25519 静态公钥。二者是不同密钥(§3.2)。因此仅凭配对载荷无法验证 devicekey 的对应关系——这个绑定由 Relay 在注册时验证(§6.1),以及由握手本身验证(连错机器则握手失败)。

2.4 短码

给扫不了码的场景。

  • 字母表:BCDFGHJKMNPQRSTVWXYZ23456789(28 字符,无元音 0 O 1 I L
  • 长度:8 个字符
  • 显示形式:XXXX-XXXX(中间一个连字符)
  • 生成:每字符取一个随机字节,ALPHABET[byte % 28]

取模引入的偏置:256 mod 28 = 4,前 4 个字符概率略高(约 +2.4%)。8 字符的熵约 38.5 bit,短码只在 15 分钟内有效且一次性(§6.2),此偏置不构成实际风险。

归一化(比较前必须执行):

  1. 转大写
  2. 删除所有非 ASCII 字母数字字符
  3. 若结果恰为 8 字符,在第 4 与第 5 字符间插入 -;否则原样返回

完整性判定:去格式化后恰为 8 字符,且每个字符都在字母表内。

陷阱:判定必须基于去格式化后的字符串。基于归一化结果判断长度是错的——9 个字符的输入归一化后仍是 9 个字符,与"8 字符 + 连字符"同长,会被误判为合法。

2.5 确认数

短码路径下,两端各自显示并由人工比对。

digits = BE_uint32( sha256("rowel-confirm" ‖ handshake_hash)[0..4] ) mod 1000000

十进制,左侧补零至 6 位

handshake_hash 是握手完成时 Noise 对称状态的 h(§3.3)。

2.6 密钥指纹

给人核对用(bridle devices 与 app 设置页显示同一个值)。

hex = uppercase( hex( sha256("rowel-identity" ‖ public_key) ) )
fingerprint = hex[0:4] "-" hex[4:8] "-" hex[8:12] "-" hex[12:16]

即前 8 字节,4 组 4 个十六进制字符,连字符分隔。例:125B-8CAC-3F65-7256


3. Noise 通道

3.1 参数

协议名Noise_IK_25519_ChaChaPoly_SHA256
DHX25519
加密ChaCha20-Poly1305
哈希SHA-256
密钥长度32 字节
认证标签16 字节
prologueUTF-8 "rowel-tunnel"

prologue 是稳定的协议族标识,不含版本。版本在握手载荷里协商(§3.3、§4.6)。

早期设计把版本写进 prologue(形如 rowel-tunnel/v1)。那是错的:版本不同会让响应方在解密消息一时就失败,此时安全通道尚未建立,任何拒绝都发不出去也无法被认证,客户端无法区分版本偏斜、连错机器、被篡改三种情况。

遵循 Noise 规范的标准初始化:h = SHA256(protocol_name)(协议名恰好 32 字节则直接用),ck = h,随后 MixHash(prologue)

3.2 密钥角色

密钥属于用途
X25519 静态密钥对双方各一Noise 身份
X25519 临时密钥对双方各一,每连接新生成前向保密
Ed25519 签名密钥对仅 Bridle向 Relay 证明身份(§6.1),派生 device

禁止跨用途复用任何密钥材料。

3.3 握手(IK 模式)

消息一,App → Bridlee, es, s, ss

e                                     32 字节临时公钥,明文
MixHash(e)
MixKey(DH(e, rs))                     rs = Bridle 静态公钥,来自配对码
EncryptAndHash(s)                     32 + 16 = 48 字节,App 静态公钥密文
MixKey(DH(s, rs))
EncryptAndHash(payload)               握手载荷密文

线上:e ‖ enc(s) ‖ enc(payload),即 32 + 48 + (len(payload) + 16) 字节。

消息一载荷(JSON,键序如下):

字段类型必需含义
versionsnumber[]发起方支持的隧道版本,偏好在前,如 [2, 1]
namestring设备显示名
clientstring客户端构建标识,如 rowel-ios/1.0 (1)
tokenstring一次性配对令牌。已知设备必须省略此键(不是发 null

省略 vs null 的区别是语义性的:省略表示"我已被认识",null 会被当作"要兑换一个空令牌"。

versions 必须非空。响应方遇到空数组或缺失该键时按 [1] 处理(兼容最早的客户端)。

消息二,Bridle → Appe, ee, se

e                                     32 字节临时公钥,明文
MixHash(e)
MixKey(DH(e, re))
MixKey(DH(e, rs))                     rs = App 静态公钥,消息一中获得
EncryptAndHash(payload)

线上:e ‖ enc(payload)

消息二载荷

字段类型必需含义
okboolean是否接受
versionnumberok=true 时必需选定的隧道版本,双方此后都按它讲话
reasonstringok=false 时必需version / unpaired / internal
supportednumber[]reason=="version" 时必需响应方支持的版本,供客户端说出哪一端旧了
machinestringok=true 时应当机器名
bridlestringok=true 时应当Bridle 版本

ok=false 后 Bridle 必须关闭连接。

版本选择规则:响应方取 versions 与自己支持集合的交集中最大的一个。交集为空时回 {ok:false, reason:"version", supported:[…]}

这个拒绝是已认证的——它走消息二,发起方能验证它确实来自持有目标静态私钥的那台机器,而不是任何人都能伪造的一条错误。这正是把版本移出 prologue 换来的东西。

Bridle 侧接受判定(顺序不可换):

  1. versions(缺失或空则视为 [1]),与自己支持的集合求交;交集为空 → refuse("version", supported)。否则选交集中最大者。
  2. 重新读取状态文件bridle pair / bridle revoke 在别的进程里跑,必须在本次握手生效,而不是下次重启)
  3. 静态公钥在已配对列表中 → 接受,更新 last-seen
  4. 否则要求 token 存在且匹配一个未过期未使用的 offer → 接受并记录设备,令牌立即作废
  5. 否则 → refuse("unpaired")

握手完成后双方各得两个方向密钥:(k_initiator→responder, k_responder→initiator),以及 handshake_hash = h

3.4 传输层

每方向一个独立的 64 位计数器,从 0 开始,每加密一条消息 +1

Nonce 构造(12 字节):

nonce[0..4]  = 0x00 0x00 0x00 0x00
nonce[4..12] = counter,小端序 u64

这是 Noise 规范的 nonce 布局:前 4 字节恒零,后 8 字节小端计数器。这是全协议唯一的小端序

密文 = ChaCha20-Poly1305(key, nonce, plaintext, aad = 空),标签附在末尾。

接收方必须用自己期望的计数器解密,成功后 +1。解密失败(篡改、乱序、重放)必须立即撕毁隧道,禁止重同步计数器后继续——能静默重同步的流是可伪造的。

禁止在同一密钥下重用计数器。


4. 隧道帧

Noise 明文即一个 JSON 对象,UTF-8 编码。所有帧有字符串字段 t 作为判别式。

编码规则:

  • 紧凑 JSON
  • 禁止转义斜杠(方法名如 goals/create 必须原样)
  • 整数必须不带小数点(8,不是 8.0
  • 顶层键顺序必须与下表一致

4.1 App → Bridle

req — 调用一个 agent 方法

类型说明
t"req"
idstringApp 铸造的关联 id,本隧道内唯一
methodstring方法名,如 session.prompt
payloadany方法载荷

hello — 重新要一份 ready

类型说明
t"hello"
versionnumberapp 期望的隧道版本
clientstring客户端构建串,bridle status 里显示

同样的内容握手载荷里已经带过一次(§3.3)。这里再发一次,是为了让重连的 app 不必区分"新隧道"和"复用的隧道" —— 两种情况都以收到 ready 结束。Bridle 收到后必须重发 ready禁止因此重置事件序号。

cancel — 放弃在途请求

| t = "cancel" | id = 要放弃的请求 id |

Bridle 必须中止对应的上游请求。未知 id 必须静默忽略。

respond — 回答审批或提问

| t = "respond" | id = 新的关联 id | message = agent 的 client-response 消息,原样透传 |

resume — 重连后补齐

| t = "resume" | since = 已持有的最高事件序号;0 表示全新订阅 |

wake — 告诉机器:我不在线时往哪儿敲

类型说明
t"wake"
tokenstring | nullAPNs device token(小写 hex)。null = 别再叫我

App 应当在每次 ready 之后重发一次:token 存在机器上,而机器会被重装、被还原、被换掉。Bridle 对无变化的重发必须静默丢弃。

帧里没有 APNs 环境字段。token 由沙盒还是生产主机签发,是苹果自己会回答的问题(错主机返回 BadDeviceToken),Relay 先试生产再退沙盒。早期版本让 app 读自己描述文件里的 aps-environment 再逐层传下来 —— 那是把猜测当事实,而且猜错时推送静默不到达、没有任何报错。

pong — 存活应答

| t = "pong" | nonce = 原样回送 |

4.2 Bridle → App

ready — 连接就绪,必须是握手后的第一帧

类型说明
t"ready"
versionnumber隧道版本
bridlestringBridle 版本
machinestring机器名
dshReachableboolean本机 agent 当前是否可达
harnessobject,可选{ url, home }:此身份指向的 agent 地址与 ROWEL_HOME 实际路径。一台机器可以跑多个 Bridle,app 靠它区分实例并在急救指引里带上正确目录。旧 Bridle 不发;app 缺省时不显示(§14)
hostany可达时为 agent 的 host.describe 值;不可达时省略
directstring[],可选本机当前可直连的地址,优先在前。空数组表示直连监听已关(app 应清掉存量地址);缺省表示 Bridle 太老不发(app 保留存量地址)
seqnumberBridle 已产生的最高事件序号

resreq 的应答

{ "t": "res", "id": "...", "result": { "ok": true, "value": ... } }
{ "t": "res", "id": "...", "result": { "ok": false, "error": { "code", "message", "details" } } }

ev — 下行事件

| t = "ev" | seq = 隧道级单调序号 | stream = "mux" | "host" | frame = agent 的 server-request 帧,原样 |

resync — 重放缓冲不足

| t = "resync" | from = Bridle 还能提供的最早序号 |

App 收到后必须重新拉取当前屏幕上的状态,而不是全部。

status — 本机 agent 起落

| t = "status" | dshReachable = boolean | detail = 不可达原因,可选 |

ping — 存活探测,间隔 25 秒

| t = "ping" | nonce = 任意字符串 |

fault — 协议级拒绝,之后连接关闭

| t = "fault" | code = version|unpaired|internal|busy | message = 人类可读 |

4.3 未知帧

收到未知 t 的一方必须忽略该帧并继续。禁止因此关闭连接。这是向前兼容的基础。

4.4 并发上限

Bridle 必须限制单条隧道的在途 req 数量。当前实现为 64,超出时以 code: "busy" 立即应答,禁止排队。

4.5 事件序号与重放

  • seq 由 Bridle 分配,每条隧道内单调递增,从 1 开始
  • Bridle 持有环形缓冲;默认容量 2000 条(EventLog 构造参数可覆盖,测试用小值验证溢出路径)
  • resume{since} 时:若 since >= 缓冲最早序号 - 1,重放 since 之后全部;否则发 resync{from}
  • 禁止静默丢弃

4.6 版本策略

版本在握手载荷里协商(§3.3),不在 prologue 里。

  • 至少同时支持当前版与上一版(N 与 N−1)。实现必须声明自己的支持集合而不是单个值。
  • 推新版的顺序固定:先发能接受双版本的 Bridle,再灰度 app。反过来会让先升级的 app 连不上还没升级的 Bridle。
  • 只有当旧版本占比降到阈值以下,才移除对它的支持。
  • 每次版本推进必须跑新旧双向互通测试:新 app ↔ 旧 Bridle、旧 app ↔ 新 Bridle。

应用层则必须向前兼容,且这条独立于版本协商:未知帧类型、未知事件类型、未知渲染意图一律容忍。即使版本相同,一端也可能带着另一端不认识的扩展。

一次性破坏:把版本移出 prologue 本身是破坏性的——prologue 一旦带上版本后缀,此后任何改动都会让握手直接失败。这必须在公开发布之前完成,那时代价是重新配对少数几台设备;上架之后再做,代价是全部用户。这是协议最后一次在没有协商机制的情况下破坏兼容。


5. Relay 线上格式

仅存在于 Bridle ↔ Relay 段。App ↔ Relay 段的 WebSocket 消息直接是 Noise 消息。

u8  type
u32 circuit (大端)
    payload

头长 5 字节。

type方向payload
Open0x01Relay→BridleJSON CircuitInfo,宣告一部手机接入
Data0x02双向一条 carrier 消息,原样
Close0x03双向UTF-8 原因文本,可为空
Wake0x04Bridle→RelayJSON WakeRequest,叫醒一部没接入的手机
Wake0x04Relay→BridleJSON { token, dead: true },APNs 说这个 token 已失效

Wake 的 circuit 恒为 0 —— 没有 circuit 正是发它的原因。

WakeRequest = { token: string, machine?: string }

Relay 收到 Wake 后向 APNs 发一条固定文案的通知。文案是 Relay 代码里的常量,WakeRequest 没有可以放正文的字段 —— 这不是"Relay 承诺不看",是没有东西可看。手机醒来后自己开隧道去机器上取内容,本地发通知。

machine 不是新泄露的信息:Relay 的目录里本来就存着机器名(GET /v1/machine/:id 就是答它)。

Relay 没配 APNs 密钥时,Wake 必须是 no-op,禁止因此断开机器。

反向的 { token, dead: true } 只在苹果明确说设备已消失时发送(HTTP 410 Unregistered,或两个主机都回 BadDeviceToken)。限流、鉴权失败、苹果 5xx、配置不全 —— 一律禁止回传 dead:Bridle 收到就会删 token,而那些都是临时故障,删掉的是一个好地址。

Bridle 禁止在注册完成前发送 Wake:Relay 对注册前的二进制帧的处理是断开连接。振铃时机若不满足,应当记住并在注册完成后补发 —— onWaiting 只触发一次,丢了就是永久丢了。

未知 type 必须拒绝(而非忽略)——这一层是二进制且长度定死,未知类型意味着解析错位。

Relay 禁止解析 Data 的 payload。


6. Relay HTTP 接口

方法路径用途
GET/healthz存活与粗粒度计数
GET/install安装脚本(curl | sh),文本
GET/v1/machine/<deviceId>该机器是否在线
POST/v1/pair/offerBridle 挂一个短码邀请
GET/v1/pair/claim?code=App 用短码换配对载荷,一次性
WS/v1/bridleBridle 常连
WS/v1/app?device=App 接入为一条 circuit

6.1 Bridle 注册

连上 /v1/bridle 后,Relay 发一个随机 nonce,Bridle 必须15 秒内回签名:

signature = Ed25519_sign( "rowel-relay-registration/v1" ‖ "\n" ‖ nonce )

Relay 验签,并校验 deviceId == base64url(sha256("rowel-device" ‖ signing_public_key)[0..16])这是 device 与签名密钥绑定的唯一强制点。

同一 deviceId 重复注册时,新连接顶掉旧连接

6.2 短码邀请

POST /v1/pair/offer

{ "code", "device", "key", "signature", "bundle", "expiresAt" }

signature = Ed25519_sign("rowel-pair-offer/v1" ‖ "\n" ‖ code)

两个域分隔符(rowel-relay-registration/v1 / rowel-pair-offer/v1)不同,因此一个签名不能被当作另一个用途重放。

Relay 侧限制(硬编码,非配置项):

限制
单帧最大32 MiB
注册超时15 秒
心跳25 秒
每机器并发 circuit8
每设备待领短码3
短码有效期上限15 分钟(请求更长会被截断)

GET /v1/pair/claim 必须在成功返回后立即作废该短码。


7. 直连接口

Bridle 监听 0.0.0.0:<port>--direct-port0 表示由系统分配)。

  • 路径:/v1/tunnelDIRECT_PATH
  • 非 WebSocket upgrade 的请求必须返回 426,且禁止返回任何 API 内容
  • WebSocket 消息直接是 Noise 消息,无 mux 头

广播给配对码的地址由网卡枚举得出:

  • 排除 internal(loopback)与非 IPv4
  • 排除 169.254/16(DHCP 失败的自赋地址,永不可路由)
  • 排序:192.168/1610/8100.64/10(tailnet)→ 172.16/12 → 其他
  • --advertise 指定的地址排在最前(机器无法自行发现的隧道域名)

8. 错误码

8.1 隧道层(res.result.error.codefault.code

code含义可重试
disconnected隧道不在
timeout上游未在期限内应答(当前 120 秒)
busy在途请求超过上限
internalBridle 内部故障
version隧道版本不匹配
unpaired设备未被认识
bad-request载荷不合法

其余错误码由上游 agent 定义,Bridle 必须原样透传,禁止改写。

8.2 判定可重试

disconnected / timeout / internal / busy 视为暂时性;其余视为终态。客户端应当只对暂时性错误自动重试。


9. 测试向量

npm run vectors      # 生成 ios/RowelTests/Fixtures/protocol-vectors.json

固定静态密钥与固定临时密钥,使整个握手确定。覆盖:

向量断言
protocolName prologue常量一致
handshake.messageOne逐字节相同(覆盖哈希链、两次 DH、两次 AEAD)
handshake.messageTwo响应方能恢复发起方身份与载荷,且产出相同
handshake.handshakeHash双方派生一致
handshake.confirmationNumber确认数一致
transport.*多条帧的密文逐字节一致(一条帧无法暴露计数器不递增)
pairing.link编解码逐字节往返
pairing.fingerprint指纹一致
pairing.shortCodeInputs归一化一致
frames[]帧编码逐字节一致

新实现应当先跑通全部向量,再接真实 Bridle。

已知不可自验的项deviceId 派生自 Ed25519 签名公钥,而配对载荷只带 X25519 静态公钥,因此客户端无法凭载荷验证 device 字段(§2.3)。向量刻意不断言这一项。