====== 云端发现、配对和签名协议 ====== 云端权威地址为 https://sync.apuos.com,WebSocket 为 wss://sync.apuos.com/v1/sync/mailbox,STUN 为 stun:sync.apuos.com:13261(UDP)。云端仅协调发现、配对和信令,不接收文件名、列表、大小、内容摘要、进度或字节。NAS 始终决定本地目录、账号、权限与最终写入。 ===== 身份与 Ed25519 封套 ===== APP 在本机创建 Ed25519 密钥。clientId 是原始 32 字节公钥 SHA-256 的前 32 个小写十六进制字符;NAS deviceId 使用同一规则。公钥 64hex,签名 128hex,随机 nonce 32hex,全部小写。私钥保存在设备安全存储,不上传云端或 NAS,不通过 URL 或日志传递。 云端成功JSON直接返回对象,没有NAS接口的data外层。失败业务JSON为{error:code},框架或代理错误也可能是文本/HTML。所有云同步路由拒绝查询参数,包括token和sessionId;GET仅通过Bearer确定自己的作用域。 以下数字时间只展示格式,真实请求必须使用当前Unix秒和新nonce,不能复制示例时间提交。所有签名 POST 的正文恰为: {"signed":"","signature":"<128小写hex>"} 对 signed 字符串的准确 UTF-8 字节执行 Ed25519 签名。没有规定必须排序键,也没有让服务端替你规范化 JSON;签名后禁止重新排键、改变空白或替换字符转义再传。接收方先验证原字节签名,再解析内部对象。内部对象拒绝未知字段。timestamp、expiresAt 是 Unix 秒,不能混成 NAS 某些状态中的毫秒。 新请求 timestamp 允许正负 120 秒,nonce 防重放。保存的 invitation/approval 按签名、目标、未来时间容差、有效期及签发到到期时长验证:仍在 5 分钟内的邀请不能因为签发已过 120 秒就被当作新请求拒绝。 ===== NAS 侧一次性准备 ===== NAS 需要运营方明确允许其设备身份使用 sync,并持有作用域正确、未撤销的设备 K;APP 不应索取该 K。NAS POST /v1/sync/nas/session,X-ApuConnect-Key 仅用于这一步设备证明,签名内部对象为 version:1、action:nas-session、deviceId、publicKey、timestamp、nonce。返回 sync bearer、expiresAt、deviceId、mailboxUrl、stunUrls。 NAS 所有者在设备端使用当前密码选择 root/path 并创建邀请。NAS 向云端提交 signed invite,字段为 version、action:invite、deviceId、invitationId、sessionId、grantId、expiresAt、timestamp、nonce。有效期最多 300 秒,返回 12 位数字 PIN。云端只看到不透明 grantId,看不到本地路径。 ===== APP 配对请求 ===== 让用户输入 12 位 PIN,并核对设备端展示的预期 deviceId,不能仅相信短码返回的设备名称。 {"version":1,"action":"pair","pin":"<12位数字>","clientId":"<32hex>","publicKey":"<64hex>","timestamp":1792108800,"nonce":"<32hex>"} 将其序列化为 signed 并签名后,POST /v1/sync/pair。成功响应对象含 bearer、expiresAt、sessionId、deviceId、nasPublicKey、invitation、mailboxUrl、stunUrls。PIN 原子消耗,不是可无限重试的长期密码。APP 验证 invitation 的 NAS 签名、预期 deviceId、会话和有效期;公钥计算出的 ID 也必须匹配。核对失败立即停止,不把 NAS 密码发给该连接。 NAS 收到 pair-request,包括 sessionId、clientId、clientPublicKey、grantId 与原 APP claim。NAS 独立验签并让所有者明确批准准确的 APP 身份/公钥。批准不是云服务器自行赋予的文件权利。 APP优先使用云sync Bearer GET /v1/sync/client/session恢复完整状态;旧GET /v1/sync/pairing仍兼容,可读取pending或approved。批准响应中 approval 是原始 NAS 签名封套,内部字段包含 version、action:approve、deviceId、clientId、clientPublicKey、sessionId、grantId、expiresAt、timestamp、nonce。APP 必须验证自己的 ID/公钥、设备/会话/授权ID、期限和签名,不能仅判断 state 字符串。 ===== WSS 认证与信令 ===== 原生客户端在 WSS 握手带 Authorization: Bearer 加云 sync bearer。浏览器不能设置握手头时,可连接后 5 秒内发送首个文本帧 {type:authenticate,bearer},最大 512 字节。无 query token。云返回 ready(actorId、role),pending 客户端可等 approved 消息。二进制帧拒绝;使用协议 Ping/Pong 与退避重连。一个 actor/bearer 同时仅有一个有效 mailbox。 所有信令仍使用同一个 signed/signature 封套。APP 是 offerer: {"version":1,"action":"signal","sessionId":"<32hex>","from":"","to":"","seq":1,"expiresAt":1792108920,"timestamp":1792108800,"nonce":"<32hex>","kind":"offer","sdp":"<最终SDP原文>","fingerprint":"<32组冒号分隔SHA256指纹>"} seq 按发送者/会话严格递增。expiresAt 不超过当前时刻后 120 秒,也不能超过批准的到期时间。kind 只允许 offer、answer、ice、end、close。 * offer/answer:仅携带 sdp 与 fingerprint,SDP 最大 16 KiB,指纹必须等于 SDP 的 SHA-256 证书指纹;仅 application 数据通道,不允许音视频或任意扩展内容。 * ice:仅 candidate、sdpMid、sdpMLineIndex;候选最大 512 字符、mid 最大 32 字符、行号 0–16;禁止 typ relay、任意 URL 或凭据。 * end/close:不附其他可选负载,不夹带文件信息。 云转发 {type:signal,envelope:<原封套>,publicKey:<发送者公钥>}。接收方独立核对批准的公钥、签名、from/to、session、seq、期限和指纹,然后才交给 WebRTC。不能用云临时提供的另一把公钥替换已批准身份。 SDP 先规范化再签名:o= 的用户名为 -,s=-,t=0 0,行尾无多余空白,保留真实 ICE 凭据与证书指纹;签名之后不再改写。每发送者每会话最多两条 SDP、64 条 ICE;重新协商需要新建经过所有者批准的会话。 ===== 失败、撤销与部署配置 ===== delivered 只表示 mailbox 交付,不证明 WebRTC 已连接;offline 表示对端当前离线,信令不会无限排队。错误帧为 {type:error,error:}。遇到重放/限额失败不要在旧会话无限增加消息,网络失败退避重试,过期后重新配对。 GET /v1/sync/status 只返回该 actor 的对端状态。NAS 通过 signed revoke 撤销会话;云端也会持续检查 allowlist、K、会话期限与撤销。NAS 本地禁用、目录权限或账号变化可以先于云 bearer 失效而阻止写入。 浏览器开发 Origin 必须由服务器 APUOS_SYNC_ALLOWED_ORIGINS 明确列出;默认不允许通配。部署由运营维护者配置 WSS 反代、UDP STUN 和设备许可,不由 APP 动态改云端地址、开放任意端口或下载设备私钥。严格禁止 TURN、relay candidate 与 ApuConnect 兜底:直连失败要明确显示并保留本地任务,文件不能偷改走云端。 ===== 能力查询与客户端会话恢复 ===== GET /v1/sync/capabilities公开,无需Bearer,不创建会话。返回service、serviceVersion、protocolVersion:1、apiVersion:v1、mode:direct-only、cloudRole:discovery-only、fileRelay:false、turn:false、fileMetadataAccepted:false、nasCredentialsAccepted:false;services包含baseUrl、mailboxUrl和stunUrls;features当前包含signed-pairing、signed-signalling、client-session、client-disconnect。未知响应字段可忽略,未知安全协议版本不能继续敏感流程。 能力限制包括HTTP/WSS消息20480字节、signed原文18432字节、SDP16384字节、candidate512字符、邀请300秒、批准3600秒、信令120秒。每发送者/会话最多72条信令、2条SDP、64条ICE,seq最大128。每NAS最多64个活跃pair和8个待领取邀请。浏览器首帧认证5秒,mailbox空闲90秒,授权约5秒重查。控制体积要计算整个外层JSON转义后的UTF-8长度,不只计算sdp字符串。 GET /v1/sync/client/session必须是当前客户端Bearer,不能传session参数、NAS Bearer或其他客户端身份。返回protocolVersion、clientId、clientPublicKey、sessionId、deviceId、grantId、state、expiresAt、serverTime、nasPublicKey、approval、clientMailboxOnline、nasMailboxOnline、mode、fileRelay、turn。pending时approval:null;approved时是原NAS签名封套。它不返回Bearer、PIN、其他会话或文件信息。服务端状态为pending仍可能200;到期、撤销、主动断开、K失效或授权关闭返回401,而不是可继续使用的revoked对象。 等待批准时建议2–5秒查询一次,离开页面或后台停轮询;传输进度来自NAS,不对云轮询文件状态。mailboxOnline只证明当前信令进程存在连接,不证明直连、登录、目录可写或备份完成。serverTime可帮助发现时钟偏差,但不能替代签名与本机可信时钟。 ===== APP主动断开自己的会话 ===== 先通过NAS直连接口暂停/结束自己的任务并关闭DataChannel,再POST /v1/sync/client/disconnect,带当前客户端Bearer和自己的Ed25519签名。内部对象严格为: {"version":1,"action":"client-disconnect","clientId":"<当前client32hex>","deviceId":"<当前nas32hex>","sessionId":"<当前session32hex>","timestamp":1792108800,"nonce":"<新32hex>"} 200裸JSON为{disconnected:true,sessionId},pending/approved都可断开。仅知道sessionId、其他密钥、NAS令牌或同一APP另一session的Bearer都不能替代。当前pair标记撤销、客户端mailbox关闭,旧Bearer后续HTTP/WSS失效;它不撤销NAS注册、设备K、其他session,也不删除已落盘数据。 在线NAS收到client-disconnected提示,包含sessionId、clientId及原envelope。NAS独立验证绑定的APP原签名、设备/会话、时间和客户端身份,只终止匹配的本地会话;不信任云提示本身,不关闭其他客户端。NAS离线时不排队,云断开成功不能证明一个脱离信令的本地直连任务已经终止,仍以NAS本地grant/ACL和任务结果为权威。 响应丢失后重试可能401,表示旧临时会话已不能继续使用,不是发布文件或完整备份成功凭据。恢复需要新邀请和批准,保留相同APP身份与目标范围可再查持久任务。nonce按客户端跨会话防重放,不要为了重试复制前一个会话用过的值。 ===== 云业务错误和来源限流 ===== ^ 状态 ^ 常见错误 ^ 处理 ^ | 400 | sync_invalid_message | 检查精确字段、签名、动作、绑定与时钟,不盲重试 | | 401 | sync_authentication_required | 停用旧Bearer,按设备端重新批准流程恢复 | | 403 | sync_origin_denied | 检查精确浏览器Origin配置 | | 409 | sync_replay | 新操作用新随机nonce,确认是否重复提交 | | 429 | sync_rate_limit、sync_signal_rate_limit | 指数退避加抖动,降低轮询和信令量 | | 503 | sync_database_unavailable | 有界重试,不转发文件到云 | | 413 | 请求体超过20KiB | 缩小控制消息,禁止夹带文件 | capabilities与client/session每受信来源120次/分钟,client/disconnect60次/分钟;其他pair与信令另有限额。JSON格式/Content-Type/外层提取错误可能返回400/415/422框架文本,先检查状态与Content-Type再有界解析。HTTPS私有会话响应no-store,不交给普通浏览器缓存。 ===== Flutter 签名实现要点与请求样例 ===== 可使用支持Ed25519和SHA-256的成熟密码库,以下为cryptography风格的Dart流程示意。发布客户端应固定依赖版本并用云端测试向量验证,不要自行实现曲线运算。创建一次身份后安全持久化,重复启动不要无意换公钥,否则原批准范围下的恢复任务不可见。 final algorithm = Ed25519(); final keyPair = await algorithm.newKeyPair(); final publicKey = await keyPair.extractPublicKey(); final signed = jsonEncode(pairFields); final signature = await algorithm.sign(utf8.encode(signed), keyPair: keyPair); final envelope = { 'signed': signed, 'signature': hexLower(signature.bytes), }; pairFields包含前述精确字段,nonce来自安全随机数。hexLower与ID摘要须按原字节运算,不对公钥hex字符串做SHA256。对收到的invitation、approval、signal用已批准的公钥和原signed UTF-8字节验签。服务器返回的示例数据不能当作预设公钥。 curl --fail-with-body --silent --show-error -H 'Content-Type: application/json' --data-binary @pair-envelope.json 'https://sync.apuos.com/v1/sync/pair' curl --fail-with-body --silent --show-error -H "Authorization: Bearer $SYNC_BEARER" 'https://sync.apuos.com/v1/sync/pairing' pair-envelope.json只含signed/signature,不保存私钥或NAS密码。浏览器CORS需预配置Origin,不能为了开发绕过TLS或使用任意通配来源。请结合 [[developer/webrtc|直连协议]] 的认证顺序:先验证云端签名关系,再验证直连身份,最后才发送NAS登录。