无需认证的授权:无密码的应用安全

认证与授权解决的是不同的问题

大多数应用都以一道关卡开始。在做任何事之前,用户必须确立身份:输入邮箱、收取验证码、设定密码,或点击魔法链接。这一步是认证,即对照中心权威保存的记录,确认某人是谁的过程。

授权回答的是另一个问题:这个人被允许做什么?团队常把两者打包成单一的“登录”流程,但它们是不同的操作。把它们分开,会浮现一个有用的洞见。

服务器无需知道用户是谁,就能执行他被允许做的事。

JustCall 正建立在这一分离之上。没有注册,没有账户表,没有密码,服务器上也没有个人数据。授权支撑着整个系统,而传统意义上的认证并不存在。让这一切成为可能的是三个构件:私钥、公钥,以及一个我们称之为主密钥的受委派角色。

公钥身份如何取代账户

你第一次打开 JustCall 时,浏览器在本地生成一个秘密,且从不传输它。它从这个秘密派生出一对密钥,公开的那一半成为你的用户 ID:

// The private key is generated on your device. The server never sees it.
privateKey = crypto.randomUUID();

// The PUBLIC half, encoded as text, becomes your user ID.
userId = await derivePublicUserId(privateKey);

标识符就是公钥本身,而不是指向它的一行数据库记录。这一个决定,消除了传统账户系统背负的多项负担:

  • 没有注册步骤。服务器在第一次看到某个 ID 时,就开始接受用它签名的请求。
  • 没有邮箱或电话号码。ID 派生自你设备上的随机秘密,而非任何个人信息。
  • 没有密码。没有需要存储、哈希或泄露的共享秘密,因为服务器只持有公钥。
  • 没有密码重置流程,也没有撞库,因为没有可供攻击的可重用凭据。

你从不向服务器透露身份。你以密码学方式证明自己持有某个公钥背后的私钥,这个证明就足以作为授权。

服务器如何授权一个请求

没有会话也没有登录令牌,每个请求都携带自己的证明。客户端构造一段载荷,用私钥签名,再把两者一起发送。在服务器端,授权归结为一次签名校验,没有会话查找,也没有对凭据库的查询:

const isValid = await verifyPayload(
  request.userId,   // the public key = the claimed identity
  request.payload,  // exactly what was signed
  request.signature,
);

if (!isValid) {
  return jsonResponse({ message: "invalid signature" }, 401);
}

一个有效签名让请求在一次操作中既真实又已授权。攻击者无法为一个自己不持有私钥的 ID 伪造请求,因为他无法产生匹配的签名。持有私钥即是权限。

主密钥:不共享秘密即可关联设备

“一台设备持有唯一密钥”的模型,在有人添加第二台设备时就会失效。如果你的手机持有你身份的唯一密钥,那你的笔记本对系统而言就是陌生人。

主密钥解决了这一点。第一台设备成为主设备,持有你的 ID 由之派生的原始秘密。每台新增设备都生成自己独立的密钥对。新设备无法以你的名义签名,而主密钥从不在设备之间复制。关联通过委派完成:主设备签署一张简短的证书,把新设备的公钥绑定到你的 ID。

// Only the master can authorize a new device.
const authorization = {
  action: "device-authorization",
  userId: identity.userId,
  devicePublicKey: newDevice.devicePublicKey, // the new device's PUBLIC key
  role: newDevice.role,
  createdAt: Date.now(),
};
const signature = await signPayload(identity.privateKey, authorization);

此后,新设备用自己的密钥签署自己的请求,服务器则对照主设备担保过的那把密钥来校验。主设备颁发了一份凭据,却从不暴露自己的秘密,正如证书颁发机构为一个自己并不持有私钥的网站担保。

由此你获得了生产级权限系统的完整词汇:角色、委派、吊销与最小权限,全部通过签名表达。主设备可以重命名或吊销任何已关联设备,被吊销的设备在下一次签名校验时失败,其活动连接随即关闭。

无账户的服务器存储什么

看看数据库里到底有什么。每个账户持有用户选择的显示名、一个可选头像,以及一份公钥设备列表。仅此而已。没有邮箱、电话号码、密码哈希或安全问题,也没有私钥,私钥从不离开用户的设备。

设想整个数据库泄露这一经典入侵场景。攻击者得到的是一组公钥(按设计就是公开的,没有对应私钥便毫无用处),以及若干用户自选的显示名。他无法冒充任何人,因为冒充需要一把服务器从未存储的私钥。他无法把用户与密码泄露库比对,因为根本不存在密码。他无法借“你的邮箱”进行钓鱼,因为从未收集过邮箱。

系统无法泄露它从不持有的数据。保护个人信息最可靠的办法是不去收集它,而一个 ID 即公钥的身份模型让这句话成为字面事实。

为何无密码授权能降低泄露风险

直接比较攻击面。一个传统的邮箱加密码应用必须防守凭据数据库、密码重置流程、邮箱与短信验证码下发、会话令牌,以及一批它如今要负责的受监管个人数据。每一项都是账户接管与数据泄露事件的反复来源。

公钥模型只需防守一件事:用户保管好自己的密钥。这是真实的取舍,因为没有中心权威能重置丢失的密钥。作为交换,上一份清单上的每一项都消失了。服务器被攻破的影响收窄到攻击者能读取公钥和显示名,而这些信息本就从不敏感。

在你自己的应用中运用公钥授权

这一模式远不止适用于通话应用:

  • 让身份成为一对密钥,让用户 ID 成为公钥。在客户端生成,永不传输私钥那一半,并让账户在服务器第一次看到有效签名时诞生。
  • 通过校验签名而非检查会话来授权。每个动作都携带一段载荷和一个签名。对规范化的字节签名,使客户端和服务器对“签了什么”达成一致。
  • 用主密钥去委派,而非去共享。当某个主体需要把信任扩展到第二台设备、一位队友或一个 API 客户端时,让根密钥为一把新密钥签署一张有范围、可吊销的证书。根秘密从不移动。
  • 只存储公开材料,这样“被泄露了会怎样”的答案始终平淡无奇。

上线前有几条约束值得留意。密钥保管成为核心问题,因此生产系统会加入恢复手段,例如社交恢复、可选的加密备份或硬件密钥。签名请求需要时间戳或 nonce 来防重放。由于没有身份,反垃圾的工作从邮箱验证转向速率限制或邀请制。这些都不会动摇该模型。它们是任何系统都有的寻常课题,只是中间不再坐着一个密码数据库。

常见问题

应用如何能在不认证的情况下授权用户?它把持有私钥当作权限。公钥充当用户 ID,每个动作都由对应的私钥签名,服务器对照公钥校验该签名。用户证明自己被允许行动,却不向一个中心账户系统证明身份。

无账户模型是否不如邮箱加密码安全?在大多数方面更安全。没有可泄露的密码数据库,没有可滥用的重置流程,也没有可丢失的个人数据。取舍在于密钥保管:如果用户在没有备份的情况下丢失密钥,没有中心权威能恢复账户。

认证和授权有什么区别?认证确立你是谁。授权确立你被允许做什么。大多数系统把它们合在一起,但一对密钥让你能做到后者而无需前者。

如果 JustCall 的服务器被攻破会怎样?攻击者会找到公钥和显示名。公钥本就该公开,没有那些从不离开你设备的私钥便毫无用处。没有可窃取的密码、邮箱或电话号码。

没有账户能用多台设备吗?可以。第一台设备作为主设备,签署一张证书,把每台新设备的公钥绑定到你的 ID。主秘密从不复制,主设备可随时吊销任何设备。

相关文章