趣谈双端离线状态下的授权认证实现
概述
昨天 Anduin 在直播时讨论了一个相当有意思的话题,即在客户端和服务端均处于离线状态,且双端之间没有任何数据交换的情况之下,如何实现客户端对服务端的临时授权。其实类似的授权机制在生活中就有案例,比如我手机上的Steam令牌应用自登录以来就从来没连上网过,但是令牌中的动态密码却能够正确地完成电脑端Steam的身份认证,不过这里电脑端的Steam还是连接了网络进行验证的。本文来尝试探讨一种双端均处于离线状态场景的临时认证办法。因为没有查阅相关资料做参考,因此本文最终的系统不一定足够完美。
一、情景导入
假设一天周末小明出去浪了,而小明的好基友小黑在没有提前打招呼的情况下突然来小明家玩儿,当小黑到了小明的家门口敲门时却发现,小明家里此刻并没有人,于是小黑打电话给小明说,“小明小明你人呢,我到你家门口了,速来击剑🤺!”
可是小明现在还在外面浪所以并不能很快地赶回家里和小黑击剑,只能先让小黑在外面晾几个小时。因为小明家里的门锁是电子的密码门锁,于是小黑想让小明把密码告诉他,让自己进去等小明,可是小明知道把自家门锁的密码告诉别人这件事实在是太危险了,尽管是告诉自己的好基友小黑也不是那么地靠谱。那么有没有什么办法能够改造一下小明家的门锁,让小黑能够临时进去一下,同时又不泄漏小明家的密码呢?
如果小明把密码直接告诉给小黑肯定是不安全的,就算后续可以回家修改密码也会让家人非常不方便。其实如果小明家里的锁是智能锁的话问题就很简单,小明直接在手机APP上远程开锁即可,又或者可以尝试在手机APP上生成一个临时密码,这个临时密码存储在服务器上,锁端只需要在验证的时候通过连接服务器进行密码校验即可。但是问题在于小明家的门锁是智障锁,压根没有网络功能,也就无法远程开锁,也不存在连接服务器验证新密码这回事。
那么如何才能让小明在远端生成一个临时密码,同时在锁端完全离线的状况下识别出这个密码,实现小明对小黑的临时的授权呢?
二、从简单的动态密码说起
其实一通分析下来很容易发现,这个问题的核心就是需要锁密码能够动态变化,达到一个临时开锁的目的。既然是要使得密码动态起来,那么一个很容易想到的办法就是将密码规则设置为动态的。比如根据时间来确定门锁的密码,使得每一天的门锁密码都不一样:
$$ f(y,m,d)=43y+8848m+6666d $$
例如今天是2021年5月25日,那么今天的密码经过函数 $f$ 计算就是297793,锁在验证的时候首先计算出297793这个值,然后将其与开门者输入的密码相比较,如果匹配则开门。在这一策略下,小明可以很放心地把这个密码告诉小黑,因为这个密码只能解锁今天的门锁,等到明天,门锁根据函数计算出来的正确密码就变成了304459,这时再用297793就无法打开门锁了。
如果要有更好的动态性,只需要向锁函数引入更精细的时间量即可,如使用下式就能够使得每分钟的门锁密码都不一样:
$$ f(y,m,d,h,min)=c_1y+c_2m+c_3+c_4h+c_5min $$
这样将密码函数写死在锁里的做法确实可以实现锁端离线状态下对小黑的临时授权,只要小明记得密码函数,他就可以通过计算得到锁端的当前密码是多少,锁因为只使用了时间进行计算因此也不必联网。但是这么做会有两个很明显的缺陷:
- 锁函数是容易被破解的。一旦锁函数被破解,那么全世界的人都能够随时计算出小明家今天的密码从而破门而入强行击剑了;
- 密码的计算很不方便。如果是分钟精度,那么小明每次需要临时密码时都得掏出纸笔计算一波。
2.1 关于缺陷一
第一个缺陷很显然,只要小黑厚着脸皮多向小明要几次密码,经过数据分析就容易得出每天密码的生成规律,进而推出锁函数,就算使用更加复杂的非线性函数,只要最终的密码与时间这种量直接相关,同样总有被破解的一天。
但其实只要对函数计算出来的密码进行进一步的处理,如哈希处理,再进行有损截取,就很难被复原了,比如:
$$ password = GetDeterministicHashCode(f(y,m,d,h,min))%1000000 $$
// 该函数名在后文中简记作 Hash
public static int GetDeterministicHashCode(string str)
{
unchecked
{
int hash1 = (5381 << 16) + 5381;
int hash2 = hash1;
for (int i = 0; i < str.Length; i += 2)
{
hash1 = ((hash1 << 5) + hash1) ^ str[i];
if (i == str.Length - 1)
break;
hash2 = ((hash2 << 5) + hash2) ^ str[i + 1];
}
return hash1 + (hash2 * 1566083941);
}
}
经过哈希函数一处理,如何计算 $f(y,m,d,h,min)$ 其实已经不要紧了,因此我们可以直接将年月日时分的文本信息作为动态量,如我现在写博文的时间18点34分的密码就为
$$ password = Hash(20215251834)%1000000 = 895870 $$
但是,实际上哈希函数也并不是什么稀奇的函数,所以单纯对当前时间做哈希同样很容易被猜到,进而被小黑破解,因此我们可以将哈希参数复杂化一些,如混上一些静态字段:
$$ password = Hash(DateTime+"Sunwish")%1000000 $$
这样一来,尽管小黑猜到了锁函数使用的是哈希码截断,也猜到了哈希参数使用了动态的时间来实现密码的变化,但是只要猜不到参数中混入了“Sunwish”这个字段,小黑就无法复现认证码。
至此第一个问题,“锁函数是容易被破解的”就算基本解决了。
2.2 关于缺陷二
对于第二个问题,即“密码的计算很不方便”,在经过第一个问题的解决之后,密码计算变得更加不方便了,现在几乎就不可能被手算出来。
好在这个算法是我们自己设计的,因此只需要开发相应的APP,在需要使用临时密码的时候打开 APP 查看即可,而这个 APP 只需要负责计算 $Hash(DateTime+"Sunwish")%1000000$ 的值并显示出来。这样,小明只需要打开 APP,然后将此时的密码告诉小黑就能让小黑打开房门,同时因为临时密码是变动的,因此小黑之后再使用这个密码是无法打开门锁的,保证了门锁的安全性。
这样一来看似问题解决了,但要命的是,这个多出来的 APP 又引发了新的更严重的安全隐患。
虽然小黑拿到的密码只能够临时使用,同时就算小黑拿到了很多的密码也难以解出密码的规律了,但是一个很严重的问题就是,现在门锁的可靠性完全依赖于小明的手机了,任何人只要进入了小明的手机,就能够随意查看小明家门的密码,更严重的是,如果小明使用的手机是安卓系统,那么小黑只需要偷偷从小明手机上把 apk 拷到自己手机上,小黑就能神不知鬼不觉地随时在自己手机上查看小明家门的密码,这肯定是不能被小明容忍的。
三、APP与锁的解耦
哦豁,既要使用 APP 来查看临时密码,又不能让别人在 APP 里看到临时密码,咋办?
3.1 密码锁
一个非常符合直觉的方法是给密码加密码,即需要输入正确的应用密码才能够查看门锁的临时密码。这个办法看似能够缓解缺陷二,因为小黑就算拿到了小明的手机,没有 APP 的查看密码就无法进入门锁密码的查看页,也就拿不到当前的门锁密码。
但其实这个方法仍有缺陷,小黑可以通过一些手段搞到小明的 APP 文件,然后对应用程序进行逆向工程拿到应用密码,甚至可以直接暴破小明的 APP,跳过应用的密码页,因此简单的密码锁并不是很靠谱。
3.1 隐式密码
另一个更精妙的保护设计是不使用显式的密码锁,而是将哈希混淆字段作为应用密码,同门锁的加密函数相耦合,使得应用密码输入成为不可绕过(不可被暴破)的门锁密码生成过程之一,同时完全避免了密码输入正确与否的回显提示,使企图的 APP 破解工作雪上加霜。耦合后的锁函数就变成了
$$ password = Hash(DateTime+AppPsw)%1000000 $$
其中 AppPsw 是由 APP 使用者来输入的,只有在输入了正确的混淆字段“Sunwish”,才能生成正确的、能被锁端接受的认证码(因为锁端 AppPsw 字段是固定的“Sunwish”,且双端运行的锁函数是一致的,因此只要参数一致双端就能生成相同的认证码实现验证,不需要网络),同时如果 AppPsw 输入错误,应用也不会报错,而是会按照错误的哈希参数生成错误的认证码,但破解者小黑是无法得知其是否正确的,在小黑的视角看来,无论输入什么内容总是能够生成一个认证码,但是生成的认证码却无法解锁门锁,让人摸不着头脑,这使得破解变得更加不可能,因为 APP 上密码正确与否的评判标准都已经不存在了。
不过还没结束,这个系统仍然存在一些隐患。由于 APP 端所谓的“密码”就是锁端哈希参数的混淆字段,而此时混淆字段是被明文写在锁端的,因此只要小黑身手不凡,还是能够通过拆解小明家的锁,从硬件电路层面破解芯片中存储的内容,即从硬件角度破解出锁中的“Sunwish”字段,然后再偷来小明的APP,输入“Sunwish”就可以正确地生成门锁密码。
因此,锁端附加字段最好要做 MD5 加密处理:
public static string GetMD5(string input)
{
// Use input string to calculate MD5 hash
using (System.Security.Cryptography.MD5 md5 = System.Security.Cryptography.MD5.Create())
{
byte[] inputBytes = System.Text.Encoding.ASCII.GetBytes(input);
byte[] hashBytes = md5.ComputeHash(inputBytes);
// Convert the byte array to hexadecimal string
StringBuilder sb = new StringBuilder();
for (int i = 0; i < hashBytes.Length; i++)
{
sb.Append(hashBytes[i].ToString("X2"));
}
return sb.ToString();
}
}
同时在 APP 端也要对称地将 AppPsw 做加密操作,即临时密码计算函数变为
$$ password = Hash(DateTime+MD5(AppPsw))%1000000 $$
以“Sunwish”为例,经过加密后存储使用,小黑只能从门锁中破解出:
$$ 1BE859CD2B258B2187E49B98F1A30201 $$
这对于小黑来说几乎不可能逆向出原密码,也就仍然无法使用小明的 APP,此时唯一的密钥“Sunwish”只有小明知道,并且只会在小明使用 APP 输入时短暂地存在于内存中,一旦生成认证码密钥就将无影无踪,只有输入了这个正确的答案才能生成正确的,能与锁端达成共识的认证码打开门锁。
四、升级门锁辣!
可以注意到整个过程中,门锁与APP均始终处于离线状态,且门锁和APP之间不存在任何数据交换,小明在 APP 中生成的认证码只有临时可用,且只有小明才能使用 APP 生成正确的认证码,其它任何人都无法从 APP 获取有效的认证码,APP 破解也因为没有密码正确与否的评判依据而举步维艰,安全性基本得到了保证,整个系统中唯一的密钥就是存在小明脑子里的“Sunwish”字段。至此,我们就解决了小明小黑的双端离线授权问题。
最后我们处理一些细节问题,并实现认证码5分钟内有效的逻辑,并将这个系统封装一下。
4.1 公共部分
其中一个问题是 int GetDeterministicHashCode(string)
函数的返回整数有可能是负数,同时经过 $%1000000$ 运算后的数值位数并不是固定的,因此我们封装一个认证码生成函数,利用哈希参数生成固定位数的正值认证码(其中 VALICODE_LENGTH
是全局常量,指明认证码的长度):
public static int GetDeterministicValidationCode(string str)
{
// 生成正值认证码,位数取值范围为 [1, VALICODE_LENGTH]
int valiCode = Math.Abs(GetDeterministicHashCode(str) % (int)Math.Pow(10, VALICODE_LENGTH));
// 认证码位数处理成固定 VALICODE_LENGTH 位,低位补零
for (int i = 0; i < VALICODE_LENGTH - valiCode.ToString().Length; i++)
{
valiCode *= 10;
}
return valiCode;
}
下面是动态参数——时间文本的封装
public static string GetDateAttach(DateTime dateTime)
{
string dateAttach =
dateTime.Year.ToString() +
dateTime.Month.ToString() +
dateTime.Day.ToString() +
dateTime.Hour.ToString() +
dateTime.Minute.ToString();
return dateAttach;
}
4.2 APP 端
生成临时认证码:
static void Generate()
{
// 输入隐式密码
Console.Write("Input password to generate a tempory offline validation code: ");
string psw = Console.ReadLine().Trim();
// 对输入的密码做一次MD5加密
string psw_md5 = GetMD5(psw);
// 取动态参数,即时间
string dateAttach = GetDateAttach(DateTime.Now);
// 混淆时间和一次加密后的密码进行认证码生成
int validationCode = GetDeterministicValidationCode(psw_md5 + dateAttach);
// 输出5分钟内有效的临时认证码(APP端只管生成认证码,多长时间有效的判定逻辑在锁端)
Console.WriteLine($"Validation code: {validationCode} (Valide in 5 minites)");
}
4.3 门锁端
定义好预先存储在门锁里的已经经过一次加密过的隐式密码:
const string psw_md5 = "1BE859CD2B258B2187E49B98F1A30201";
对认证码进行有效性验证:
static void Check()
{
// 输入待验证的认证码
Console.Write("Please input validation code: ");
string validationCode = Console.ReadLine().Trim();
// 计算包含当前时刻的过去5分钟有效认证码,共5条
List<string> validList = new List<string>();
for (int i = 0; i < 5; i++)
{
// 过去5分钟的五条动态参数
DateTime dateAttach = DateTime.Now - TimeSpan.FromMinutes(i);
// 5个动态参数计算出的五条认证码
validList.Add(GetDeterministicValidationCode(psw_md5 + GetDateAttach(dateAttach)).ToString());
}
// 只要输入的认证码同五条有效认证码的任意一个匹配,则认证通过
if(validList.IndexOf(validationCode) != -1)
{
Console.WriteLine("Code is valid.");
}
else
{
Console.WriteLine("Code is invalid");
}
}
4.3 效果演示
- 感谢你赐予我前进的力量